Erlang 的Socket编程

介绍

英文原文 http://jerith.za.net/writings/erlangsockettut.html

我学习 Erlang 已经有一段时间了,想的是做一个网络游戏之类的东西。然而,我没有找到一个适合我水平的像样的 Erlang socket 编程教程。因此我决定自己写一个。

我针对的是有一定编程经验的人,不过倒不需要对函数式编程有什么经验。还要了解一些TCP和Socket的基础知识,对Erlang多少有一点了解,这样效果会更好。我推荐看一下 Erlang 入门。

本教程按照一些基本的步骤,从无到有地创建 Bogochat。使用了迭代的方法,从简单的东西开始,逐步建立更加复杂的系统。

修改

2007-03-16

  • 改为调用 fun(),这样就不需要导出进程函数名。
  • 将多个复合的 register/spawn 调用分为单个 spawn 这样 register 会更明确。
  • io:format/2 改为 io:fwrite/1.
  • 重写 bogostrip/1 ,使用更好的 string:tokens

感谢在 the erlang-talk mailing list 上对这篇教程第一版做评论和建议的人们。

第一步: Echo

第一步是侦听 socket, 接受接入连接,然后再做其他的。TCP socket 函数在 gen_tcp ,常用的有 listen, accept, sendrecv 等等。以下是一个简单的 echo 服务器,只接受一个连接,对方发过来什么就回复什么。

-module(echo).
-export([listen/1]).

%% 侦听socket的 TCP 选项。第一个 list 元语
%% 表示我们想接收数据的是字节列表(如
%% 字符串),而不是二进制的对象。
%% 其他的参数请参考 Erlang 文档。

-define(TCP_OPTIONS,[list, {packet, 0}, {active, false}, {reuseaddr, true}]).

%% 侦听指定的端口,接受第一个连接,
%% 然后启动 echo 循环。

listen(Port) ->
    {ok, LSocket} = gen_tcp:listen(Port, ?TCP_OPTIONS),
    {ok, Socket} = gen_tcp:accept(LSocket),
    do_echo(Socket).

%% 进入循环,将socket收到的东西发回去。
%% 在客户端断开连接时退出。

do_echo(Socket) ->
    case gen_tcp:recv(Socket, 0) of
        {ok, Data} ->
            gen_tcp:send(Socket, Data),
            do_echo(Socket);
        {error, closed} ->
            ok
    end.

第二步: 多个客户端的 echo

第一个 echo 服务器能工作,但是只能接受一个人,完了以后还要重新启动。下一步就是要改成能同时接受多个客户。最简单的做法就是对每个客户端起一个进程,然后主进程再回去等待新连接。

-module(multiecho).
-export([listen/1]).

%% 侦听socket的 TCP 选项。第一个 list 元语
%% 表示我们想接收数据的是字节列表(如
%% 字符串),而不是二进制的对象。
%% 其他的参数请参考 Erlang 文档。

-define(TCP_OPTIONS,[list, {packet, 0}, {active, false}, {reuseaddr, true}]).

%% 侦听指定的端口,接受第一个连接,
%% 然后启动 echo 循环。

listen(Port) ->
    {ok, LSocket} = gen_tcp:listen(Port, ?TCP_OPTIONS),
    do_accept(LSocket).

%% 接受以后创建处理过程,
%% 末尾调用 do_accept 再次进入侦听

do_accept(LSocket) ->
    {ok, Socket} = gen_tcp:accept(LSocket),
    spawn(fun() -> do_echo(Socket) end),
    do_accept(LSocket).

%% 进入循环,将socket收到的东西发回去。
%% 在客户端断开连接时退出。

do_echo(Socket) ->
    case gen_tcp:recv(Socket, 0) of
        {ok, Data} ->
            gen_tcp:send(Socket, Data),
            do_echo(Socket);
        {error, closed} ->
            ok
    end.

使用 fun() 而不是直接的函数名,这样就可以不用导出要创建的进程函数,净化名称空间。

第三步: 最简单的聊天

现在我们可以做多用户的交互了。因为要向创建连接以外的其他连接发送数据,这样就需要有一个客户端管理进程来保存连接和断开的信息。

接受进程在接受新连接后,告诉客户端管理器有新连接加入。而客户端处理进程发送数据和断开连接的通知。

-module(basicchat).
-export([listen/1]).

%% 侦听socket的 TCP 选项。第一个 list 元语
%% 表示我们想接收数据的是字节列表(如
%% 字符串),而不是二进制的对象。
%% 其他的参数请参考 Erlang 文档。

-define(TCP_OPTIONS,[list, {packet, 0}, {active, false}, {reuseaddr, true}]).

%% 侦听指定的端口,接受第一个连接,
%% 然后启动 echo 循环。同时要启动 client_manager,
%% 这个是服务器的入口点。

listen(Port) ->
    Pid = spawn(fun() -> manage_clients([]) end),
    register(client_manager, Pid),
    {ok, LSocket} = gen_tcp:listen(Port, ?TCP_OPTIONS),
    do_accept(LSocket).

%% 接受以后创建处理过程,
%% 末尾调用 do_accept 再次进入侦听
%% 还要通知 client_manager 有新的连接要加入

do_accept(LSocket) ->
    {ok, Socket} = gen_tcp:accept(LSocket),
    spawn(fun() -> handle_client(Socket) end),
    client_manager ! {connect, Socket},
    do_accept(LSocket).

%% handle_client/1 替换掉 do_echo/1 ,因为现在所有事情都要
%% 通过 client_manager 完成。断开时通知 client_manager
%% socket 已经关闭,数据就当是发送完成了。

handle_client(Socket) ->
    case gen_tcp:recv(Socket, 0) of
        {ok, Data} ->
            client_manager ! {data, Data},
            handle_client(Socket);
        {error, closed} ->
            client_manager ! {disconnect, Socket}
    end.

%% 维护 socket 列表,处理连接和断开消息,
%% 并互相传递数据

manage_clients(Sockets) ->
    receive
        {connect, Socket} ->
            io:fwrite("Socket connected: ~w~n", [Socket]),
            NewSockets = [Socket | Sockets];
        {disconnect, Socket} ->
            io:fwrite("Socket disconnected: ~w~n", [Socket]),
            NewSockets = lists:delete(Socket, Sockets);
        {data, Data} ->
            send_data(Sockets, Data),
            NewSockets = Sockets
    end,
    manage_clients(NewSockets).

%% 给列表中的所有 socket 发消息。通过 lists:foreach/2 遍历
%% 列表中的每个 socket,再调用 gen_tcp:send 来发送数据

send_data(Sockets, Data) ->
    SendData = fun(Socket) ->
                       gen_tcp:send(Socket, Data)
               end,
    lists:foreach(SendData, Sockets).

第四步: Bogochat

如果还要做得更多一点,那就得给客户端管理器更多信息。本步骤将每个客户端一个名称,并且只给除发送者以外的在线客户端发数据。

注意,客户管理进程保存所有客户端的数据,而底层的网络处理进程只知道 socket. 而从另一方面来说,parser函数,必须知道客户端的所有信息,这样才能根据状态来改变其行为,以及更改状态标志。

-module(bogochat).
-export([listen/1]).

-define(TCP_OPTIONS,[list, {packet, 0}, {active, false}, {reuseaddr, true}]).

-record(player, {name=none, socket, mode}).

%% 要接受进入连接,必须侦听 TCP 端口。
%% 这也是整个服务器的入口点。
%% 因此启动 client_manager 进程后给它取个名字,
%% 这样其他进程就能方便地找到它。

listen(Port) ->
    {ok, LSocket} = gen_tcp:listen(Port, ?TCP_OPTIONS),
    Pid = spawn(fun() -> maintain_clients([]) end),
    register(client_manager, Pid),
    do_accept(LSocket).

%% 接受连接时,收到新创建的 socket。
%% 因为要接受多个连接,给每个socket创建一个进程,
%% 然后回到侦听socket上等待下一个连接。

do_accept(LSocket) ->
    case gen_tcp:accept(LSocket) of
        {ok, Socket} ->
            spawn(fun() -> handle_client(Socket) end),
            client_manager ! {connect, Socket};
        {error, Reason} ->
            io:fwrite("Socket accept error: ~s~n", [Reason])
    end,
    do_accept(LSocket).

%% 客户端进程要做的事是等待收到数据,然后转发给 client_manager 进程,
%% 由它来决定下一步做什么。如果客户端断开了,通知 client_manager 后退出

handle_client(Socket) ->
    case gen_tcp:recv(Socket, 0) of
        {ok, Data} ->
            client_manager ! {data, Socket, Data},
            handle_client(Socket);
        {error, closed} ->
            client_manager ! {disconnect, Socket}
    end.

%% 这里是 client_manager 进程的主循环。它维护着客户端列表,
%% 对客户端的输入调用对应的处理过程。

maintain_clients(Players) ->
    io:fwrite("Players:~n"),
    lists:foreach(fun(P) -> io:fwrite(">>> ~w~n", [P]) end, Players),
    receive
        {connect, Socket} ->
            Player = #player{socket=Socket, mode=connect},
            send_prompt(Player),
            io:fwrite("client connected: ~w~n", [Player]),
            NewPlayers =  [Player | Players];
        {disconnect, Socket} ->
            Player = find_player(Socket, Players),
            io:fwrite("client disconnected: ~w~n", [Player]),
            NewPlayers = lists:delete(Player, Players);
        {data, Socket, Data} ->
            Player = find_player(Socket, Players),
            NewPlayers = parse_data(Player, Players, Data),
            NewPlayer = find_player(Socket, NewPlayers),
            send_prompt(NewPlayer)
    end,
    maintain_clients(NewPlayers).

%% find_player 是一个辅助过程,根据指定的 socket, 
%% 从客户端列表中查出对应的客户端记录

find_player(Socket, Players) ->
    {value, Player} = lists:keysearch(Socket, #player.socket, Players),
    Player.

%% delete_player 返回除指定的客户端以外的客户端列表。
%% 根据socket 从列表中删除指定的客户端,而不是根据整个记录来,
%% 是因为有可能列表中保存的记录不一致(版本不同)。

delete_player(Player, Players) ->
    lists:keydelete(Player#player.socket, #player.socket, Players).

%% 发送恰当的提示给客户端。当前发送的唯一提示是
%% 在客户端连接时的初始 "Name: " 。

send_prompt(Player) ->
    case Player#player.mode of
        connect ->
            gen_tcp:send(Player#player.socket, "Name: ");
        active ->
            ok
    end.

%% 发送指定数据给所有活动的客户端

send_to_active(Prefix, Players, Data) ->
    ActivePlayers = lists:filter(fun(P) -> P#player.mode == active end,
                                 Players),
    lists:foreach(fun(P) -> gen_tcp:send(P#player.socket, Prefix ++ Data) end,
                  ActivePlayers),
    ok.

%% 这里并没有做太多的解析。如果加入更多特性,将会做出修改。
%% 现在只是在第一次连接时给客户端命名,
%% 以后所有的消息都做为要发送的数据。

parse_data(Player, Players, Data) ->
    case Player#player.mode of
        active ->
            send_to_active(Player#player.name ++ ": ",
              delete_player(Player, Players), Data),
            Players;
        connect ->
            UPlayer = Player#player{name=bogostrip(Data), mode=active},
            [UPlayer | delete_player(Player, Players)]
    end.

%% 辅助过程, 用来在使用名称前清理其中多余的符号。称为 bogostrip
%% 而不是 strip 是因为它返回的是第一个连续的不匹配的字符串,
%% 而不是去掉第一个匹配的字符串后返回。

bogostrip(String) ->
    bogostrip(String, "/r/n/t ").

bogostrip(String, Chars) ->
    [Stripped|_Rest] = string:tokens(String, Chars),
    Stripped.

结论

下一步就是把这个创建成 gen_server 程序,不过这是以后的事了。如果有反馈信息请发邮件给 firxen at gmail.

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值