英文原文
我学习 Erlang 已经有一段时间了,想的是做一个网络游戏之类的东西。然而,我没有找到一个适合我水平的像样的 Erlang socket 编程教程。因此我决定自己写一个。
我针对的是有一定编程经验的人,不过倒不需要对函数式编程有什么经验。还要了解一些TCP和Socket的基础知识,对Erlang多少有一点了解,这样效果会更好。我推荐看一下 Erlang 入门。
本教程按照一些基本的步骤,从无到有地创建 Bogochat。使用了迭代的方法,从简单的东西开始,逐步建立更加复杂的系统。
2007-03-16
感谢在 the erlang-talk mailing list 上对这篇教程第一版做评论和建议的人们。
第一步是侦听 socket, 接受接入连接,然后再做其他的。TCP socket 函数在 gen_tcp ,常用的有 listen, accept, send 和 recv 等等。以下是一个简单的 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 服务器能工作,但是只能接受一个人,完了以后还要重新启动。下一步就是要改成能同时接受多个客户。最简单的做法就是对每个客户端起一个进程,然后主进程再回去等待新连接。
-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).
如果还要做得更多一点,那就得给客户端管理器更多信息。本步骤将每个客户端一个名称,并且只给除发送者以外的在线客户端发数据。
注意,客户管理进程保存所有客户端的数据,而底层的网络处理进程只知道 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.
转载:http://blog.csdn.net/jlbnet/archive/2007/12/08/1924520.aspx