“客户端-服务端”模型

注册进程的一个主要用途就是用于支持“客户端-服务端”模型编程。在这个模型中有一个服务端管理着一些资源,一些客户端通过向服务端发送请求来访问这些资源,如图5.4所示。要实现这个模型,我们需要三个基本组件——一个服务端,一个协议和一个访问库。我们将通过几个例子来阐明基本原则。

在先前的程序5.2中展示的counter模块里,每一根计数器都是一个服务端。客户端通过调用模块所定义的函数来访问服务端。

_images/5.4.png图5.4

程序5.5中展示的例子是一个可以用于电话交换机系统里分析用户所拨打的号码的服务端。start()会调用spawn并将新建的进程注册为number_analyser,这就完成了号码分析服务端的创建。之后服务端进程会在server函数中不断循环并等待服务请求。如果收到了一个形如{add_number,Seq,Dest}的请求,该号码序列(Seq)以及对应的目标进程(Dest),以及分析出结果之后将会发送的目的地,会被添加到查找表中。这是由函数insert完成的。之后消息ack将会被发送到请求的进程。如果服务端收到了形如{analyse,Seq}的消息,那么它将通过调用lookup完成号码序列Seq的分析,并将包含分析结果的消息发回发送请求的进程。我们在这里没有给出函数insertlookup的具体定义,因为那对于我们目前讨论的问题而言并不重要。

客户端发送到服务端的请求消息包含了自己的进程标识符。这让服务端可以向客户端发送回复。发回的回复消息中也包含了一个“发送者”的标识,在这里就是服务端的注册名字,这使得客户端可以选择性地接收回复消息。这比简单地等待第一个消息到达要更加安全一些——因为客户端的邮箱中也许已经有了一些消息,或者其他进程也许会在服务端回复之前给客户端发送一些消息。

程序 5.5

  1. -module(number_analyser).
  2. -export([start/0,server/1]).
  3. -export([add_number/2,analyse/1]).
  4.  
  5. start() ->
  6. register(number_analyser,
  7. spawn(number_analyser, server, [nil])).
  8.  
  9. %% The interface functions.
  10. add_number(Seq, Dest) ->
  11. request({add_number,Seq,Dest}).
  12.  
  13. analyse(Seq) ->
  14. request({analyse,Seq}).
  15.  
  16. request(Req) ->
  17. number_analyser ! {self(), Req},
  18. receive
  19. {number_analyser, Reply} ->
  20. Reply
  21. end.
  22.  
  23. %% The server.
  24. server(AnalTable) ->
  25. receive
  26. {From, {analyse,Seq}} ->
  27. Result = lookup(Seq, AnalTable),
  28. From ! {number_analyser, Result},
  29. server(AnalTable);
  30. {From, {add_number, Seq, Dest}} ->
  31. From ! {number_analyser, ack},
  32. server(insert(Seq, Dest, AnalTable))
  33. end.

现在我们已经实现了服务端并定义了协议。我们在这里使用了一个异步协议,每个发送到服务端的请求都会有一个回复。在服务端的回复中我们使用number_analyser(亦即服务端的注册名字)作为发送者标识,这样做是因为我们不希望暴露服务端的Pid

接下来我们定义一些接口函数用于以一种标准的方式访问服务端。函数add_numberanalyse按照上面描述的方式实现了客户端的协议。它们都使用了局部函数request来发送请求并接收回复。

程序5.6

  1. -module(allocator).
  2. -export([start/1,server/2,allocate/0,free/1]).
  3.  
  4. start(Resources) ->
  5. Pid = spawn(allocator, server, [Resources,[]]),
  6. register(resource_alloc, Pid).
  7.  
  8. % The interface functions.
  9. allocate() ->
  10. request(alloc).
  11.  
  12. free(Resource) ->
  13. request({free,Resource}).
  14.  
  15. request(Request) ->
  16. resource_alloc ! {self(),Request},
  17. receive
  18. {resource_alloc,Reply} ->
  19. Reply
  20. end.
  21.  
  22. % The server.
  23. server(Free, Allocated) ->
  24. receive
  25. {From,alloc} ->
  26. allocate(Free, Allocated, From);
  27. {From,{Free,R}} ->
  28. free(Free, Allocated, From, R)
  29. end.
  30.  
  31. allocate([R|Free], Allocated, From) ->
  32. From ! {resource_alloc,{yes,R}},
  33. server(Free, [{R,From}|Allocated]);
  34. allocate([], Allocated, From) ->
  35. From ! {resource_alloc,no},
  36. server([], Allocated).
  37.  
  38. free(Free, Allocated, From, R) ->
  39. case lists:member({R,From}, Allocated) of
  40. true ->
  41. From ! {resource_alloc,ok},
  42. server([R|Free], lists:delete({R,From}, Allocated));
  43. false ->
  44. From ! {resource_alloc,error},
  45. server(Free, Allocated)
  46. end.

下一个例子是如程序5.6中所示的一个简单的资源分配器。服务端通过一个需要管理的初始的资源列表来启动。其他进程可以向服务端请求分配一个资源或者将不再使用的资源释放掉。

服务端进程维护两个列表,一个是未分配的资源列表,另一个是已分配的资源列表。通过将资源在两个列表之间移动,服务端可以追踪每个资源的分配情况。

当服务端收到一个请求分配资源的消息时,函数allocate/3会被调用,它会检查是否有未分配的资源存在,如果是则将资源放在回复给客户端的yes消息中发送回去,否则直接发回no消息。未分配资源列表是一个包含所有未分配资源的列表,而已分配资源列表是一个二元组{Resource,AllocPid}的列表。在一个资源被释放之前,亦即从已分配列表中删除并添加到未分配列表中去之前,我们首先会检查它是不是一个已知的资源,如果不是的话,就返回error

讨论

接口函数的目的是创建一个抽象层并隐藏客户端和服务端之间使用的协议的细节。一个服务的用户在使用服务的时候并不需要知道协议的细节或者服务端所使用的内部数据结构以及算法。一个服务的具体实现可以在保证外部用户接口一致性的情况下自由地更改这些内部细节

此外,回复服务请求的进程还有可能并不是实际的服务器进程,而是一个不同的进程——所有的请求都被委转发到它那里。实际上,“一个”服务器可能会是一个巨大的进程网络,这些互通的进程一起实现了给定的服务,但是却被接口函数隐藏起来。应当发布的是接口函数的集合,它们应当被暴露给用户,因为这些函数提供了唯一合法的访问服务端提供的服务的方式。

在Erlang中实现的“客户端-服务端”模型是非常灵活的。monitorremote procedure call之类的机制可以很容易地实现出来。在某些特殊的情况下,具体实现也可以绕过接口函数直接与服务端进行交互。由于Erlang并没有强制创建或使用这样的接口函数,因此需要由系统设计师来保证在需要的时候创建它们。Erlang并没有提供用于远程过程调用之类的现成解决方案,而是提供了一些基本原语用于构造这样的解决方案。