Evolution of interfaces
Let's go back again to a more theoretical level. When we are creating an API, which is in any capacity public, we are signing ourselves up for an undertaking which may be more complex than might appear it first. In some ways, it is similar to choosing our SQL database schema and the caution and care we must exercise every time we need to make changes to it, especially if it is in production with large amounts of critical data.
There are two ways an API can be public. First is internally, wherein one API is shared by several components written by different teams of developers. These components might have entirely different release cycles and it may be infeasible or even outright impossible to keep rewriting and redeploying them side-to-side. Therefore we have to design an API that is public, that means it is carefully selected and intended for the maximum degree of stability and backward and perhaps also forward compatibility, about which we shall speak in a moment.
The second way an API can be public is if it is straight out exposed to end users / customers. This may be either because a piece of software that's ran by the customers uses this API, or we may just be exposing a public API intended for developers. These developers than build on said API and expect it to be stable within reason. If it weren't, and if they are building a commercial, or hell, even a large OSS, project, our API may no longer be as attractive because we provide them an unsteady ground to stand on.
The same goes with software ran on the machines of customers. It is impossible, or very difficult and costly, to force all customers to update to the latest version. Therefore we cannot lightly make incompatible changes lest we break it for the customers. This is not good from a business stand point. If you have ever done analytics about version usage, you will know that even after it has been obsolete for years, very old versions of software may still be ran by a few stragglers. These stragglers sadly cannot be taken lightly, while they may be proverbial computer geronts who simply do not care about updating, they might also be government institutions which may pose big and dangerous clients, and to which we may even be bound contractually (hopefully, Braiins will not get into this situation for a long long time ;-)).
This stresses the importance of backward compatibility and forward compatibility, we should therefore look into how these terms are defined, and then we can look at some exact steps of how gRPC tries to make designing reasonable APIs less difficult.
However, keep in mind that no level of crutches from the side of the technology you are using is a substitute for a well design API. If you have significant experience with the Object Oriented approach to programming, you should know that just making everything public willy nilly is a particularly bad idea.
Some common sense principles apply. You may be familiar with the terms below, but it is still not a bad idea to review them.
Encapsulation
Encapsulation is an OOP term referring to the notion of bundling data along with the methods that operate on said data into a single logical unit. You may be familiar with this concept in the form of classes. Rust has similar encapsulation with type definitions and their accompanying impl blocks. That is a key difference of Rust - implementation is split from data definition.
However, encapsulation goes beyond that. You should bundle data and operations related to a particular topic or subject matter together also. A bad API is one that is hard to navigate, and that is from both the user standpoint and a developer standpoint. A developer that cannot easily get a full picture of what he or she is developing may introduce bugs by forgetting to update obsolete code, which was located in an irrational place.
Within the terms of gRPC, this means making sensible services, and putting related things to the same modules.
Information hiding
A related term is information hiding. To put it simply: keep your API as small as possible. While we have all been tempted by the sweet calls of engineering joy that leads us on a slippery slope that leads down to the dark world of over-engineering, it is really a bad idea.
There is multiple benefits to keeping API small. The first benefit is that it is less work to develop, another benefit is that it is less work to maintain and ensure compatibility for, and finally, we are reducing attack area by it.
The bigger your API is, the more likely it is that a certain usage of your API can introduce invalid state into your application, which may lead to exploits, bugs or even straight up crashes and failures. Therefore, it is best to keep an API small.
This does not mean that you have to go all the way down to the pioneer C lang designers' way of rugged minimalism, as that is at the cost of ergonomics, especially or the end users who may not have as deep of an understanding of the product as you do.
Forward compatibility
Forward compatibility, also known as upward compatibility, is a design feature of an API that allows a system to accept input intended for a later version of itself. This means that an application will work if it has to process data created by a newer version of itself, or its counterpart (for server-client architectures, you can have a server instance accept and process data from a newer version of the client).
To be considered forward-compatible, the input must be processed gracefully, this usually means either ignoring it, or ignoring it and kindly letting the user or the other program know that it is too old, and this and this piece of data has been ignored.
For example, Kafka is both forward and backward compatible, within reason. You can use clients that speak a newer version of Kafka with an older broker. However, you might be losing out on performance, or special features.
Another place where you might commonly see forward compatibility is with archiving
software, especially when it comes to compression. For example archives compressed
with Google's snappy could still be decompressed with many tools that did not know
this algorithm.
Backward compatibility
Backward compatibility, is the property of an API (regardless of whether we are talking an operating system, product or other technology), that allows for interoperability with an older version.
In the previous example, we would be backward compatible, if a new server would have no issue processing input from an older client. If you modify your system in such a way that no longer allows backward compatibility beyond a certain point, it is generally referred to as breaking backward compatibility.
The differences in input between the versions also have to be dealt with gracefully. A common example might be the fact that some fields sent by an older client but expected by the newer server might be missing. Therefore, the server has to be able to handle that some data is not present without failure or crash, or otherwise compromising its functionality in a harmful way.
For example, metrics may be an area where we encounter backwards compatibility. Older clients might not send all the metrics a newer client would, but the server is fine with it, it just does not display said metric, or displays a default value.
It is typically a good idea to design your system in such a way that you can distinguish between missing data and data that sends an invalid default value, so that you can discover potential issues.
Sometimes, breaking backward compatibility may be unavoidable, even though you would like to still support older clients for one reason or another. In these situations, you are forced to keep alive two different versions of the API. This may lead to a significant split in your codebase. The easiest way to deal with this would be to add a translation layer, that translates one version to another. When keeping multiple major versions of an API alive, these versions must be clearly distinguished.
For example, you may add a version component to a web API, such as :
- /v1/... -> old version
- /v2/... -> new version
Of course, some clients might be so old that there is no one to adapt them to the version distinction, in those cases, you have to provide backwards compatibility in the form of automatically interpreting requests without a version specified as the older versions, however, in all cases, make sure to weigh the pros and cons.
Sometimes, it is just the time to cut your losses and stop supporting ancient things.
Being extremely backwards compatible can lead to an overly large and overly messy codebase.
For instance, consider xterm, this terminal emulator, often considered the ubiquitous one,
or the ed of terminal emulators, which is even on many distributions bundled with Xorg itself,
has a very large codebase that supports devices older than time itself, none of which
are likely to even be used in production at this point.
You may end up with some software features being present only for the purpose of "historical reasons". The Jargon File refers to these as hysterical reasons / raisins, and has this to say about it:
(also hysterical raisins) A variant on the stock phrase “for historical reasons”, indicating specifically that something must be done in some stupid way for backwards compatibility, and moreover that the feature it must be compatible with was the result of a bad design in the first place. “All IBM PC video adapters have to support MDA text mode for hysterical reasons.” Compare bug-for-bug compatible.
That speaks a lot about considering how to do things.
gRPC forward and backward compatibility
gRPC with protobuf version 3 leads us to design our systems to be both forwards and backwards compatible. The fact that Protocol Buffers are utilized are a key component in this.
Here is an example of some protocol buffers, a message representing a student who can choose between two language classes, and then we store which group he or she is in as a string in the field:
message Student {
int32 student_id = 1;
oneof language_course {
string lang_eng = 2;
string lang_fre = 3;
}
}
In many other formats, this would not be very good, as we might be surprised when a field is missing.
However, protocol buffers version 3 makes everything optional by default, which in Rust translates
to Option<T>, that expresses that the value may not be present and forces us to handle that eventuality.
If you do not handle it regardless, then it was your explicit choice and when it proverbially shoots you in the foot, it is completely on you. Therefore, the only mistake we may find in the definition above is the possibility that a student might choose a French course. The mere thought sends shivers down my spine, but luckily, we can rectify that.
The nice thing to do is to first mark this field as deprecated. We can also add a more sensible choice:
message Student {
int32 student_id = 1;
oneof language_course {
string lang_eng = 2;
string lang_fre = 3 [deprecated = true];
string lang_finnish = 4;
}
}
Now that's a language to grow some hair on your chest! Eventually, we may even stop parsing this field at all. Therefore, we set the field as reserved:
message Student {
reserved 3;
int32 student_id = 1;
oneof language_course {
string lang_eng = 2;
string lang_finnish = 4;
}
}
We have successfully deleted the French and there is peace in the universe.
The fact that the fields are numbered is quite important. This means that the order in which the fields are written is not really important and that we have a notion of an actual slot in the messages. We will therefore not run into issues where a field has been removed and we are no longer expecting it, and so fields get shifted and we receive corrupted date or unparseable messages.
This functionality helps messages to be both backwards and forwards compatible. Server has to handle that all things might be missing, the client does not have to care about sending things that it is not capable of and receiving responses it is not expecting.
Since we are prescribed protocol buffers, we can reasonably expect this behavior to stand.