API设计中使用命令模式替代RPC

banq


我们已经拥有众多 API 架构风格,例如 REST、RPC和 SOAP 等,将命令模式添加到其中具有以下优势,它可以成为这一角色的良好候选者:

  • 提供一种对交易事务进行建模的方法。命令共享一个通用接口,并且可以一次执行多个操作,因此非常适合此目的。
  • 允许将用户操作保存为命令列表,这对于记录用户活动、重放操作或实施审计机制很有用。
  • 提供撤消/重做功能。
  • 遵循开放/封闭原则。可以轻松添加新命令,而无需修改现有代码。
  • 增强可测试性,因为可以单独测试命令。

命令模式与 RPC比较
因为它们很相似:两种方法都涉及在服务器上执行任意操作。

一、概念不同
最明显的区别是:

  • 命令模式通过命令进行操作
  • 而 RPC 依赖于函数。

二、类似表述
尽管如此,它们在通过网络传输时看起来是一样的:

Cmd { prop1, prop1, … }   -> cmd_type, prop1, prop2, … 
Fn   ( param1, param2, … )   -> fn_id, param1, param2, …

三、每次执行的操作数
与Command命令模式不同,RPC每次只能执行一个动作,不太方便,我们看下面的函数组成:
bar(foo())

RPC 建议发出两个请求或使用类似foobar 的函数来减少往返次数,后一种选择并不理想。

降低延迟问题的愿望又会影响通信接口,这个两难问题很广泛和复杂,所以,Cap'n Proto提供了自己的解决方案 solution ,更详细地描述了这个问题。

另一方面,函数组合对于命令模式来说不是问题:


FooBarCmd
  Exec(r Receiver) { r.bar(r.foo()) }

  
一般来说,它允许通过单个请求执行无限数量的操作,而不会增加接口复杂性或影响性能。

四、函数内部的命令
RPC 实际上可以使用命令模式来实现。在这种情况下,函数可以简单地向服务器发送命令:

func foo() { send FooCmd }
func bar() { send BarCmd }
func foobar() { send FooBarCmd }

因此,我们只需要知道如何处理命令。这些知识用途更广泛,甚至可以改进现有的 RPC 系统。

命令模式挑战
在提供更灵活和通用的抽象的同时,命令模式也引入了一些挑战:

1、需要服务器主动以某种方式区分一个命令与另一个命令。

  • 解决方案:在每个命令之前,发送其类型。

2、命令必须返回结果,并且每个命令都可以有自己的结果。

  • 解决方案:命令本身可以负责返回一个或多个结果,而不是返回结果。

Invoker
  r Receiver
  Invoke(cmd Cmd, t Transport) {
    cmd.Exec(r, t) // 通过使用Transport, 命令发送回结果the command sends results back.
    ...
  }

3、命令在执行过程中可能会出错,如何处理?
解决方案:

  • 如果想让客户端知道这个错误,命令可以返回错误结果。
  • 另一种情况是,命令可以终止与客户端的连接,并向调用者返回错误。


Invoker 
  r Receiver 
  Invoke (cmd Cmd, t Transport) error { 
    err = cmd. Exec (r, t) // 如果 Exec() 方法返回错误,则
   
// 与客户端的连接将终止。这正是
   
// 命令在发送结果失败后可能执行的操作。
     ... 
  }

为了限制其执行时间,命令必须知道服务器何时收到它。此时间可能与执行开始时间不同。解决方案:命令可以将其作为参数接收。

Invoker 
  r Receiver 
  Invoke (at Time, cmd Cmd, t Transport) error { 
    err = cmd.Exec ( at, r, t) // 'at' 参数表示收到命令的时间。     ...   }

    

这就是适合我们需求的命令模式的样子。

要看到它的实际作用,我们需要考虑另外一件事。
序列化格式
要将数据发送到某个地方,必须先将其转换为字节序列。这可以通过多种方式完成,这就是为什么存在如此多的序列化格式。需要考虑的最重要的指标之一是格式使用的字节数。我们需要通过网络传输的字节数越少,我们的应用程序就会越快。

MUS 格式就是基于这些想法而创建的。它几乎不使用元数据,实际上是一种相当简单的格式。我不想重复太多,所以这里有一个 link 链接,你可以在那里阅读更多相关信息。

这就是理论的全部内容。

具体实现
上述想法已经以两个库的形式在 Golang 中实现:cmd-stream-go 和 mus-go。

1、cmd-stream-go 
cmd-stream-go是一个高性能客户端-服务器库,它实现了命令模式并且:

  • 可以通过 TCP、TLS 或相互 TLS 工作。
  • 具有异步客户端,它仅使用一个连接来发送命令和接收结果。
  • 支持服务器流式传输,即一个命令可以返回多个结果(不直接支持客户端流式传输,但也可以实现)。
  • 支持重新连接功能。
  • 支持保活功能。
  • 可以与各种序列化格式一起使用。
  • 具有模块化架构。


2、mus-go
mus-go是一个 MUS 格式的序列化器,它:

  • 表示一组序列化原语,不仅可用于实现 MUS,还可用于实现其他序列化格式。
  • 有流媒体版本。
  • 可以在 32 位和 64 位系统上运行。
  • 可以在解组时验证和跳过数据。
  • 支持指针。
  • 可以序列化图形或链表等数据结构。
  • 支持数据版本控制。
  • 支持 oneof 功能。
  • 支持无序反序列化。
  • 支持零分配反序列化。

此外,正如您在基准测试 benchmarks中所看到的,它表现出了出色的性能。

cmd-stream-go/MUS 比 gRPC/Protobuf快 3 倍左右。

概括
发送命令是一个非常好的抽象。它类似于 RPC,但并不限制我们只能执行一项操作。此外,命令模式既可以替代 RPC,也可以用作构建 RPC 的工具。它还提供了上述几个优点,并且已经具有高性能实现。所有这些都使命令模式成为 API 架构风格的绝佳选择。