Command Pattern as an API Architecture Style
Would you like a fast and flexible interaction with your API? To achieve this goal, it is necessary to:
- Decide on the API architectural style.
- Choose the serialization format.
- Use efficient implementations of both.
It’s always better to solve a general problem by breaking it down into smaller parts, so let’s take a closer look at each.
API Architectural Style
We already have numerous API architectural styles, such as REST, RPC, and SOAP, to name a few. I would like to add our old friend to them — the Command pattern, or more precisely, the Command pattern over the network. Offering the following advantages, it can be a good candidate for this role:
- Provides a way to model transactions. Commands share a common interface and can perform multiple actions at a time, making them ideal for this purpose.
- Allows to save user actions as a list of commands, which can be useful for logging user activities, replaying operations, or implementing auditing mechanisms.
- Provides Undo/Redo functionality.
- Follows the open/closed principle. New commands can be added easily without modifying existing code.
- Enhances testability, as commands can be tested in isolation.
Let’s compare it with RPC.
Command Pattern vs RPC
Why RPC? Because they are similar — both approaches involve performing an arbitrary action on the server.
I. Different Concepts
The most obvious difference is that the Command pattern operates with commands, whereas RPC relies on functions.
II. Similar Representation
Despite that, they look the same when transmitted over the network:
Cmd { prop1, prop1, … } -> cmd_type, prop1, prop2, …
Fn ( param1, param2, … ) -> fn_id, param1, param2, …
That’s quite unexpected, isn’t it?
III. Number of Actions at a Time
Unlike the Command pattern, RPC can perform only one action at a time, which is not very convenient. Let’s look at the following function composition:
bar(foo())
RPC suggests to make two requests or have a function like foobar, to reduce the number of round trips. The latter option, by the way, is not ideal. The desire to get rid of latency problems will affect the communication interface — it will become more broad and complex. This is, actually, what the guys from Cap’n Proto say. Offering own solution they describe this problem in more detail.
On the other hand, function composition is not an issue for the Command pattern:
// Don’t take this code too seriously, it looks this way for the sake of
// simplicity.
FooBarCmd
Exec(r Receiver) { r.bar(r.foo()) }
In general it allows to perform an unlimited number of actions with a single request, without adding interface complexity or compromising performance.
IV. Commands Inside Functions
RPC can actually be implemented using the Command pattern. In this case, functions can simply send commands to the server:
// Don’t take this code too seriously, it looks this way for the sake of
// simplicity.
func foo() { send FooCmd }
func bar() { send BarCmd }
func foobar() { send FooBarCmd }
Thus, all we need to know is how to deal with commands. This knowledge is more versatile and allows to improve even existing RPC systems.
Command Pattern Challenges
While offering a more flexible and general abstraction, the Command pattern also introduces some challenges:
- Server should somehow distinguish one command from another. Solution: Before each command, send its type.
- Commands must return results, and each command can have its own. Solution: Instead of returning, the command itself can be responsible for sending one or more results back.
Invoker
r Receiver
Invoke(cmd Cmd, t Transport) {
cmd.Exec(r, t) // Using Transport, the command sends results back.
...
}
- During execution, a command may encounter an error. How should it be handled? Solution: If we want the client to know about this error, the command can send it back as a result. In another case, the command can terminate the connection with the client, returning an error to the Invoker.
Invoker
r Receiver
Invoke(cmd Cmd, t Transport) error {
err = cmd.Exec(r, t) // If the Exec() method returns an error, the
// connection to the client will be terminated. This is exactly what the
// command may do after it unsuccessfully sends the result.
...
}
- To limit its execution time, the command must know when it was received by the server. This time may differ from the start of execution. Solution: The command can receive it as a parameter.
Invoker
r Receiver
Invoke(at Time, cmd Cmd, t Transport) error {
err = cmd.Exec(at, r, t) // The 'at' parameter indicates the time when the
// command was received.
...
}
That’s how the Command pattern, adapted to our needs, might look. To see it in action, we need to consider one more thing.
Serialization Format
To send data somewhere, it must first be converted into a sequence of bytes. This can be done in various ways, which is why so many serialization formats exist. One of the most important metrics to consider is the number of bytes used by the format. The fewer bytes we need to transfer over the network, the faster our application will be.
The MUS format was created with these thoughts in mind. It uses almost no metadata and is actually a fairly simple format. I don’t want to repeat myself a lot, so here’s a link where you can read more about it.
And that’s all for the theory.
Concrete Implementations
The ideas described above have already been implemented for Golang in the form of two libraries: cmd-stream-go and mus-go.
cmd-stream-go
cmd-stream-go is a high-performance client-server library that implements the Command pattern and:
- Can work over TCP, TLS or mutual TLS.
- Has an asynchronous client, that uses only one connection for both sending commands and receiving results.
- Supports the server streaming, i.e. a command can send back multiple results (client streaming is not directly supported, but can also be implemented).
- Supports reconnect feature.
- Supports keepalive feature.
- Can work with various serialization formats.
- Has a modular architecture.
mus-go
mus-go is a MUS format serializer, it:
- Represents a set of serialization primitives that can be used to implement not only MUS but also other serialization formats.
- Has a streaming version.
- Can run on both 32 and 64-bit systems.
- Can validate and skip data while unmarshalling.
- Supports pointers.
- Can serialize data structures such as graphs or linked lists.
- Supports data versioning.
- Supports oneof feature.
- Supports out-of-order deserialization.
- Supports zero allocation deserialization.
In addition, as you can see in the benchmarks, it demonstrates excellent performance:
| NAME | ITERATIONS COUNT | NS/OP | B/SIZE | B/OP | ALLOCS/OP |
|--------------|------------------|-------|--------|------|-----------|
| mus | 18103663 | 57 | 58 | 0 | 0 |
| protobuf | 2460066 | 503.1 | 70 | 271 | 4 |
| json | 421777 | 2697 | 150 | 600 | 9 |
, where
- NAME — Serializer name.
- ITERATIONS COUNT — Number of iterations performed by the benchmark in about 1 second.
- NS/OP — Nanoseconds per operation.
- B/SIZE — Number of bytes used to encode the data.
- B/OP — Number of bytes allocated per operation.
- ALLOCS/OP — Number of allocations per operation.
Examples and Benchmarks
Among these examples, you can find one where cmd-stream-go is used as a communication tool for the RPC approach. As for benchmarks, cmd-stream-go/MUS is about 3 times faster than gRPC/Protobuf:
Summary
Sending commands is a pretty good abstraction. It’s similar to RPC, but doesn’t limit us to just one action. Moreover, the Command pattern can either replace RPC or be used as a tool for building it. Also it offers several advantages mentioned above and already has the high-performance implementation. All of this makes the Command pattern a great choice for an API architectural style.