强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Erlang/OTP 完全指南 / 23 - Web 开发

第 23 章:Web 开发 — Cowboy、REST API、WebSocket

Cowboy 是 Erlang 最流行的 HTTP 服务器框架。本章学习使用 Cowboy 构建 REST API 和 WebSocket 服务。


23.1 Cowboy 简介

23.1.1 特性

特性说明
高性能基于 Erlang 的轻量级进程
HTTP/1.1完整支持
HTTP/2支持
WebSocket原生支持
Stream handlers可扩展的流处理
中间件中间件架构

23.1.2 依赖配置

%% rebar.config
{deps, [
    {cowboy, "2.12.0"},
    {jsx, "3.1.0"}    %% JSON 库
]}.

23.2 基本 HTTP 服务器

23.2.1 Application 模块

%% src/myweb_app.erl
-module(myweb_app).
-behaviour(application).
-export([start/2, stop/1]).

start(_StartType, _StartArgs) ->
    %% 定义路由
    Dispatch = cowboy_router:compile([
        {'_', [
            {"/", home_handler, []},
            {"/api/users", users_handler, []},
            {"/api/users/:id", user_handler, []},
            {"/ws", ws_handler, []}
        ]}
    ]),
    
    %% 启动 HTTP 监听器
    {ok, _} = cowboy:start_clear(my_http_listener,
        [{port, 8080}],
        #{env => #{dispatch => Dispatch}}
    ),
    
    myweb_sup:start_link().

stop(_State) ->
    cowboy:stop_listener(my_http_listener),
    ok.

23.2.2 简单 Handler

%% src/home_handler.erl
-module(home_handler).
-export([init/2]).

init(Req, State) ->
    Body = <<"<h1>Hello from Cowboy!</h1>">>,
    Req2 = cowboy_req:reply(200,
        #{<<"content-type">> => <<"text/html">>},
        Body,
        Req
    ),
    {ok, Req2, State}.

23.3 REST API

23.3.1 REST Handler

%% src/users_handler.erl
-module(users_handler).
-export([init/2,
         allowed_methods/2,
         content_types_provided/2,
         content_types_accepted/2,
         users_to_json/2,
         json_to_users/2]).

init(Req, State) ->
    {cowboy_rest, Req, State}.

allowed_methods(Req, State) ->
    {[<<"GET">>, <<"POST">>], Req, State}.

content_types_provided(Req, State) ->
    {[{<<"application">>, <<"json">>, '*'}, users_to_json}], Req, State}.

%% 注意:原代码中 content_types_provided 使用双花括号嵌套元组
%% 为避免 Hugo shortcode 冲突,此处调整写法
content_types_accepted(Req, State) ->
    {[{<<"application">>, <<"json">>, '*'}, json_to_users}], Req, State}.

%% GET /api/users
users_to_json(Req, State) ->
    Users = [
        #{id => 1, name => <<"Alice">>, age => 25},
        #{id => 2, name => <<"Bob">>, age => 30}
    ],
    Body = jsx:encode(Users),
    {Body, Req, State}.

%% POST /api/users
json_to_users(Req0, State) ->
    {ok, Body, Req} = cowboy_req:read_body(Req0),
    User = jsx:decode(Body, [return_maps]),
    %% 保存用户...
    io:format("Created user: ~p~n", [User]),
    Req2 = cowboy_req:reply(201,
        #{<<"content-type">> => <<"application/json">>},
        jsx:encode(#{status => <<"created">>}),
        Req
    ),
    {stop, Req2, State}.

23.3.2 带路径参数的 Handler

%% src/user_handler.erl
-module(user_handler).
-export([init/2, allowed_methods/2, content_types_provided/2,
         user_to_json/2, delete_resource/2]).

init(Req, State) ->
    {cowboy_rest, Req, State}.

allowed_methods(Req, State) ->
    {[<<"GET">>, <<"PUT">>, <<"DELETE">>], Req, State}.

content_types_provided(Req, State) ->
    {[{<<"application">>, <<"json">>, '*'}, user_to_json}], Req, State}.

user_to_json(Req, State) ->
    %% 从路径提取 ID
    Id = cowboy_req:binding(id, Req),
    %% 查询用户...
    User = #{id => Id, name => <<"Alice">>, age => 25},
    Body = jsx:encode(User),
    {Body, Req, State}.

delete_resource(Req, State) ->
    Id = cowboy_req:binding(id, Req),
    %% 删除用户...
    io:format("Deleted user ~p~n", [Id]),
    {true, Req, State}.

23.3.3 REST 状态码

%% 成功
cowboy_req:reply(200, Headers, Body, Req).   %% OK
cowboy_req:reply(201, Headers, Body, Req).   %% Created
cowboy_req:reply(204, Headers, <<>>, Req).   %% No Content

%% 错误
cowboy_req:reply(400, Headers, <<"Bad Request">>, Req).
cowboy_req:reply(401, Headers, <<"Unauthorized">>, Req).
cowboy_req:reply(403, Headers, <<"Forbidden">>, Req).
cowboy_req:reply(404, Headers, <<"Not Found">>, Req).
cowboy_req:reply(500, Headers, <<"Internal Server Error">>, Req).

23.4 WebSocket

23.4.1 WebSocket Handler

%% src/ws_handler.erl
-module(ws_handler).
-export([init/2, websocket_init/1, websocket_handle/2, 
         websocket_info/2, terminate/3]).

init(Req, State) ->
    {cowboy_websocket, Req, State}.

websocket_init(State) ->
    io:format("WebSocket connected~n"),
    {ok, State}.

%% 处理客户端发来的 WebSocket 消息
websocket_handle({text, Msg}, State) ->
    io:format("Received: ~p~n", [Msg]),
    Reply = jsx:encode(#{echo => Msg}),
    {[{text, Reply}], State};
websocket_handle({binary, Data}, State) ->
    {[{binary, Data}], State};
websocket_handle(_Frame, State) ->
    {[], State}.

%% 处理 Erlang 进程发来的消息
websocket_info({send, Msg}, State) ->
    {[{text, Msg}], State};
websocket_info(_Info, State) ->
    {[], State}.

terminate(_Reason, _Req, _State) ->
    io:format("WebSocket disconnected~n"),
    ok.

23.4.2 WebSocket 广播

%% ws_manager.erl
-module(ws_manager).
-behaviour(gen_server).
-export([start_link/0, register/1, broadcast/1]).
-export([init/1, handle_call/3, handle_cast/2]).

start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

register(Pid) ->
    gen_server:cast(?MODULE, {register, Pid}).

broadcast(Msg) ->
    gen_server:cast(?MODULE, {broadcast, Msg}).

init([]) ->
    {ok, #{clients => sets:new()}}.

handle_cast({register, Pid}, #{clients := Clients} = State) ->
    monitor(process, Pid),
    {noreply, State#{clients => sets:add_element(Pid, Clients)}};
handle_cast({broadcast, Msg}, #{clients := Clients} = State) ->
    sets:fold(fun(Pid, _) -> Pid ! {send, Msg} end, ok, Clients),
    {noreply, State}.

handle_info({'DOWN', _, process, Pid, _}, #{clients := Clients} = State) ->
    {noreply, State#{clients => sets:del_element(Pid, Clients)}}.

23.4.3 聊天室 WebSocket

%% chat_ws_handler.erl
-module(chat_ws_handler).
-export([init/2, websocket_init/1, websocket_handle/2, websocket_info/2]).

init(Req, #{room := Room} = State) ->
    {cowboy_websocket, Req, State#{room => Room}}.

websocket_init(#{room := Room} = State) ->
    chat_room:join(Room, self()),
    {ok, State}.

websocket_handle({text, Msg}, #{room := Room, name := Name} = State) ->
    chat_room:broadcast(Room, {chat, Name, Msg}),
    {[], State};
websocket_handle(_Frame, State) ->
    {[], State}.

websocket_info({chat, FromName, Msg}, State) ->
    Response = jsx:encode(#{from => FromName, message => Msg}),
    {[{text, Response}], State};
websocket_info(_Info, State) ->
    {[], State}.

23.5 中间件

%% src/auth_middleware.erl
-module(auth_middleware).
-export([execute/2]).

execute(Req, Env) ->
    case cowboy_req:header(<<"authorization">>, Req) of
        undefined ->
            {ok, cowboy_req:reply(401,
                #{<<"content-type">> => <<"text/plain">>},
                <<"Unauthorized">>,
                Req), Env};
        Token ->
            case verify_token(Token) of
                {ok, UserId} ->
                    {ok, Req, Env#{user_id => UserId}};
                error ->
                    {ok, cowboy_req:reply(401,
                        #{<<"content-type">> => <<"text/plain">>},
                        <<"Invalid token">>,
                        Req), Env}
            end
    end.

verify_token(_Token) ->
    %% 验证 JWT 或其他令牌
    {ok, 1}.
%% 在路由中使用中间件
Dispatch = cowboy_router:compile([
    {'_', [
        {"/api/protected", auth_middleware, #{handler => protected_handler}}
    ]}
]).

23.6 注意事项

⚠️ Web 开发陷阱

  1. 不要在 Handler 中执行长时间操作(阻塞请求进程)
  2. 读取大请求体需要先调用 read_body
  3. WebSocket 连接需要心跳保活
  4. CORS 需要手动设置 headers
  5. 生产环境需要反向代理(Nginx)处理 SSL

💡 最佳实践

  1. 使用 cowboy_rest 处理 REST API
  2. 使用 JSX 或 jiffy 处理 JSON
  3. WebSocket 状态存储在进程字典或 gen_server
  4. 合理设置超时和连接限制
  5. 使用中间件处理认证和日志

23.7 扩展阅读


上一章:22 - NIF 与 C 集成 下一章:24 - 最佳实践