Protocol Buffers - Google's data interchange format (grpc依赖)
https://developers.google.com/protocol-buffers/
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
404 lines
20 KiB
404 lines
20 KiB
1 year ago
|
# Life of an Edition
|
||
|
|
||
|
**Author:** [@mcy](https://github.com/mcy)
|
||
|
|
||
|
How to use Protobuf Editions to construct a large-scale change that modifies the
|
||
|
semantics of Protobuf in some way.
|
||
|
|
||
|
## Overview
|
||
|
|
||
|
This document describes how to use the Protobuf Editions mechanism (both
|
||
|
editions, themselves, and [features](protobuf-editions-design-features.md)) for
|
||
|
designing migrations and large-scale changes intended to solve a particular kind
|
||
|
of defect in the language.
|
||
|
|
||
|
This document describes:
|
||
|
|
||
|
* How features are added to the language.
|
||
|
* How editions are defined and "proclaimed".
|
||
|
* How to build different kinds of large-scale changes.
|
||
|
* Tooling in `protoc` to support large-scale changes.
|
||
|
* An OSS strategy.
|
||
|
|
||
|
## Defining Features
|
||
|
|
||
|
There are two kinds of features:
|
||
|
|
||
|
* Global features, which are the fields of `proto.Features`. In this document,
|
||
|
we refer to them as `features.<name>`, e.g. `features.enum`.
|
||
|
* Language-scoped features, which are defined in a typed extension field for
|
||
|
that language. In this document, we refer to them as
|
||
|
`features.(<lang>).name`, e.g. `features.(proto.cpp).legacy_string`.
|
||
|
|
||
|
Global features require a `descriptor.h` change, and are relatively heavy
|
||
|
weight, since defining one will also require providing helpers in `Descriptor`
|
||
|
wrapper classes to avoid the need for users to resolve inheritance. Because they
|
||
|
are not specific to a language, they need to be carefully, visibility
|
||
|
documented.
|
||
|
|
||
|
Language-scoped features require only a change in a backend's feature extension,
|
||
|
which has a smaller blast radius (except in C++ and Java). Often these are
|
||
|
relevant only for codegen and do not require reflective introspection.
|
||
|
|
||
|
Adding a feature is never a breaking change.
|
||
|
|
||
|
### Feature Lifetime
|
||
|
|
||
|
In general, features should have an *original default* and a *desired default*:
|
||
|
features are intended to gradually flip from one value to another throughout the
|
||
|
ecosystem as migrations progress. This is not always true, but this means most
|
||
|
features will be bools or enums.
|
||
|
|
||
|
Any migration that introduces a feature should plan to eventually deprecate and
|
||
|
remove that feature from both our internal codebase and open source, generally
|
||
|
with a multi-year horizon. Features are *transient*.
|
||
|
|
||
|
Removing a feature is a breaking change, but it does not need to be tied to an
|
||
|
edition. Feature removal in OSS must thus be batched into a breaking release.
|
||
|
Deletion of a feature should generally be announced to OSS a year in advance.
|
||
|
|
||
|
### Do's and Don'ts
|
||
|
|
||
|
Here are some things that we could use features for, very broadly:
|
||
|
|
||
|
* Changing the generated API of any syntax production (name, behavior,
|
||
|
signature, whether it is generated at all). E.g.
|
||
|
`features.(proto.cpp).legacy_string`.
|
||
|
* Changing the serialization encoding of a field (so long as it does not break
|
||
|
readers). E.g., `features.packed`, eventually `features.group_encoding`.
|
||
|
* Changing the deserialization semantics of a field. E.g., `features.enum`,
|
||
|
`features.utf8`.
|
||
|
|
||
|
Although almost any semantic change can be feature-controlled, some things would
|
||
|
be a bit tricky to use a feature for:
|
||
|
|
||
|
* Changing syntax. If we introduce a new syntax production, gating it doesn't
|
||
|
do people much good and is just noise. We should avoid changing how things
|
||
|
are spelled. In Protobuf's history, it has been incredibly rare that we have
|
||
|
needed to do this.
|
||
|
* Shape of a descriptor. Features should generally not cause fields, message,
|
||
|
or enum descriptors to appear or disappear.
|
||
|
* Names and field numbers. Features should not change the names or field
|
||
|
numbers of syntax entities as seen in a descriptor. This is separate from
|
||
|
using features to change generated API names.
|
||
|
* Changing the wire encoding in an incompatible way. Using features to change
|
||
|
the wire format has some long horizons and caveats described below.
|
||
|
|
||
|
## Proclaiming an Edition
|
||
|
|
||
|
An *edition* is a set of default values for all features that `protoc`'s
|
||
|
frontend, and its backends, understand. Edition numbers are announced by
|
||
|
protobuf-team, but not necessarily defined by us. `protoc` only defines the
|
||
|
edition defaults for global features, and each backend defines the edition
|
||
|
defaults for its features.
|
||
|
|
||
|
### Total Ordering of Editions
|
||
|
|
||
|
The `FileDescriptorProto.edition` field is a string, so that we can avoid nasty
|
||
|
surprises around needing to mint multiple editions per year: even if we mint
|
||
|
`edition = "2022";`, we can mint `edition = "2022.1";` in a pinch.
|
||
|
|
||
|
However, protobuf-team does not define editions, it only proclaims them.
|
||
|
Third-party backends are responsible for changing defaults across editions. To
|
||
|
minimize the amount of synchronization, we introduce a *total order* on
|
||
|
editions.
|
||
|
|
||
|
This means that a backend can pick the default not by looking at the edition,
|
||
|
but by asking "is this proto older than this edition, where I introduced this
|
||
|
default?"
|
||
|
|
||
|
The total order is thus: the edition string is split on `'.'`. Each component is
|
||
|
then ordered by `a.len < b.len && a < b`. This ensures that `9 < 10`, for
|
||
|
example.
|
||
|
|
||
|
By convention, we will make the edition be either the year, like `2022`, or the
|
||
|
year followed by a revision, like `2022.1`. Thus, we have the following total
|
||
|
ordering on editions:
|
||
|
|
||
|
```
|
||
|
2022 < 2022.0 < 2022.1 < ... < 2022.9 < 2022.10 < ... < 2023 < ... < 2024 < ...
|
||
|
```
|
||
|
|
||
|
(**Note:** The above edition ordering is updated in
|
||
|
[Edition Naming](edition-naming.md).)
|
||
|
|
||
|
Thus, if an imaginary Haskell backend defines a feature
|
||
|
`feature.(haskell).more_monads`, which becomes true in 2023, the backend can ask
|
||
|
`file.EditionIsLaterThan("2023")`. If it becomes false in 2023.1, a future
|
||
|
version would ask `file.EditionIsBetween("2023", "2023.1")`.
|
||
|
|
||
|
This means that backends only need to change when they make a change to
|
||
|
defaults. However, backends cannot add things to editions willy-nilly. A backend
|
||
|
can only start observing an edition after protobuf-team proclaims the next
|
||
|
edition number, and may not use edition numbers we do not proclaim.
|
||
|
|
||
|
### Proclamation
|
||
|
|
||
|
"Proclamation" is done via a two-step process: first, we announce an upcoming
|
||
|
edition some months ahead of time to OSS, and give an approximate date on which
|
||
|
we plan to release a non-breaking version that causes protoc to accept the new
|
||
|
edition. Around the time of that release, backends should make a release adding
|
||
|
support for that edition, if they want to change a default. It is a faux-pas,
|
||
|
but ultimately has no enforcement mechanism, for the meaning of an edition to
|
||
|
change long (> 1 month) after it has been released.
|
||
|
|
||
|
We promise to proclaim an edition once per calendar year, even if first-party
|
||
|
backends will not use it. In the event of an emergency (whatever that means), we
|
||
|
can proclaim a `Y.1`, `Y.2`, and so on. Because of the total order, only
|
||
|
backends that desperately need a new edition need to pay attention to the
|
||
|
announcement. As we gain experience, we should define guidelines for third
|
||
|
parties to request an unscheduled edition bump, but for the time being we will
|
||
|
deal with things case-by-case.
|
||
|
|
||
|
We may want to have a canonical way for finding out what the latest edition is.
|
||
|
It should be included in large print on our landing page, and `protoc
|
||
|
--latest-edition` should print the newest edition known to `protoc`. The intent
|
||
|
is for tooling that wants to generate `.proto` templates externally can choose
|
||
|
to use the latest edition for new messages.
|
||
|
|
||
|
## Large-scale Change Templates
|
||
|
|
||
|
The following are sketches of large-scale change designs for feature changes we
|
||
|
would like to execute, presented as example use-cases.
|
||
|
|
||
|
### Large-scale Changes with No Functional Changes: Edition Zero
|
||
|
|
||
|
We need to get the ecosystem into the `"editions"` syntax. This migration is
|
||
|
probably unique because we are not changing any behavior, just the spelling of a
|
||
|
bunch of things.
|
||
|
|
||
|
We also need to track down and upgrade (by hand) any code that is using the
|
||
|
value of `syntax`. This will likely be a manual large-scale change performed
|
||
|
either by Busy Beavers or a handful of protobuf-team members furnished with
|
||
|
appropriate stimulants (coffee, diet mountain dew, etc). Once we have migrated
|
||
|
95% of callers of `syntax`, we will mark all accessors of that field in various
|
||
|
languages as deprecated.
|
||
|
|
||
|
Because the value of `syntax` becomes unreliable at this point, this will be a
|
||
|
breaking change.
|
||
|
|
||
|
Next, we will introduce the features defined in
|
||
|
[Edition Zero Features](edition-zero-features.md). We will then implement
|
||
|
tooling that can take a `proto2` or `proto3` file and add `edition = "2023";`
|
||
|
and `option features.* = ...;` as appropriate, so that each file retains its
|
||
|
original behavior.
|
||
|
|
||
|
This second large-scale change can be fully automated, and does not require
|
||
|
breaking changes.
|
||
|
|
||
|
### Large-scale Changes with Features Only: Immolation of `required`
|
||
|
|
||
|
We can use features to move fields off of `features.presence = LEGACY_REQUIRED`
|
||
|
(the edition’s spelling of `required`) and onto `features.presence =
|
||
|
EXPLICIT_PRESENCE`.
|
||
|
|
||
|
To do this, we introduce a new value for `features.presence`,
|
||
|
`ALWAYS_SERIALIZE`, which behaves like `EXPLICIT_PRESENCE`, but, if the has-bit
|
||
|
is not set, the default is serialized. (This is sort of like a cross between
|
||
|
`required` and `proto3` no-label.)
|
||
|
|
||
|
It is always safe to turn a proto from `LEGACY_REQUIRED` to `ALWAYS_SERIALIZE`,
|
||
|
because `required` is a constraint on initialization checking, i.e., that the
|
||
|
value was present. This means the only requirement is that old readers not
|
||
|
break, which is accomplished by always providing *a* value. Because `required`
|
||
|
fields don't set the value anyways, this is not a behavioral change, but it now
|
||
|
permits writers to veer off of actually setting the value.
|
||
|
|
||
|
After an appropriate build horizon, we can assume that all readers are tolerant
|
||
|
of a potentially missing value (even though no writer would actually be omitting
|
||
|
it). At this point we can migrate from `ALWAYS_SERIALIZE` to
|
||
|
`EXPLICIT_PRESENCE`. If a reader does not see a record for the field, attempting
|
||
|
to access it will produce the default value; it is not likely that callers are
|
||
|
actually checking for presence of `required` fields, even though that is
|
||
|
technically a thing you can do.
|
||
|
|
||
|
Once all required fields have gone through both steps, `LEGACY_REQUIRED` and
|
||
|
`ALWAYS_SERIALIZE` can be removed as variants (breaking change).
|
||
|
|
||
|
### Large-scale Changes with Editions: absl::string_view Accessors
|
||
|
|
||
|
In C++, a `string` or `bytes` typed field has accessors that produce `const
|
||
|
std::string&`s. The missed optimizations of doing this are well-understood, so
|
||
|
we won't rehash that discussion.
|
||
|
|
||
|
We would like to migrate all of them to return `absl::string_view`, a-la
|
||
|
`ctype = STRING_PIECE`.
|
||
|
|
||
|
To do this, we introduce `features.(proto.cpp).legacy_string`[^1], a boolean
|
||
|
feature by default true. When false on a field of appropriate type, it does the
|
||
|
needful and causes accessors to become representationally opaque.
|
||
|
|
||
|
The feature can be set at file or field scope; tooling (see below) can be used
|
||
|
to minimize the diff impact of these changes. Changing a field may also require
|
||
|
changing code that was previously assuming they could write `std::string x =
|
||
|
proto.string_field();`. This has the usual "unspooling string" migration
|
||
|
caveats.
|
||
|
|
||
|
Once we have applied 95% of internal changes, we will upgrade the C++ backend at
|
||
|
the next edition to default `legacy_string` to false in the new edition. Tooling
|
||
|
(again, below) can be used to automatically delete explicit settings of the
|
||
|
feature throughout our internal codebase, as a second large-scale change. This
|
||
|
can happen in parallel to closing the loop on the last 5% of required internal
|
||
|
changes.
|
||
|
|
||
|
Once we have eliminated all the legacy accessors, we will remove the feature
|
||
|
(breaking change).
|
||
|
|
||
|
### Large-scale Changes with Wire Format Break: Group-Encoded Messages
|
||
|
|
||
|
It turns out that encoding and decoding groups (end-marker-delimited
|
||
|
submessages) is cheaper than handling length-delimited messages. There are
|
||
|
likely CPU and RAM savings in switching messages to use the group encoding.
|
||
|
Unfortunately, that would be a wire-breaking change, causing old readers to be
|
||
|
unable to parse new messages.
|
||
|
|
||
|
We can do what we did for `packed`. First, we modify parsers to accept message
|
||
|
fields that are encoded as either groups or messages (i.e., `TYPE_MESSAGE` and
|
||
|
`TYPE_GROUP` become synonyms in the deserializer). We will let this soak for
|
||
|
three years[^2] and bide our time.
|
||
|
|
||
|
After those three years, we can begin a large-scale change to add
|
||
|
`features.group_encoded` to message fields throughout our internal codebase
|
||
|
(note that groups don't actually exist in editions; they are just messages with
|
||
|
`features.group_encoded`). Because of our long waiting period, it is (hopefully)
|
||
|
unlikely that old readers will be caught by surprise.
|
||
|
|
||
|
Once we are 95% done, we will upgrade protoc to set `features.group_encoded` to
|
||
|
true by default in new editions. Tooling can be used to clean up features as
|
||
|
before.
|
||
|
|
||
|
We will probably never completely eliminate length-delimited messages, so this
|
||
|
is a rare case where the feature lives on forever.
|
||
|
|
||
|
## Large-scale Change Tooling
|
||
|
|
||
|
We will need a few different tools for minimizing migration toil, all of which
|
||
|
will be released in OSS. These are:
|
||
|
|
||
|
* The features GC. Running `protoc --gc-features foo.proto` on a file in
|
||
|
editions mode will compute the minimal (or a heuristically minimal, if this
|
||
|
proves expensive) set of features to set on things, given the edition
|
||
|
specified in the file. This will produce a Protochangifier `ProtoChangeSpec`
|
||
|
that describes how to clean up the file.
|
||
|
|
||
|
* The editions "adopter". Running `protoc --upgrade-edition -I... file.proto`
|
||
|
figure out how to update `file.proto` from `proto2` or `proto3` to the
|
||
|
latest edition, adding features as necessary. It will emit this information
|
||
|
as a `ProtoChangeSpec`, implicitly running features GC.
|
||
|
|
||
|
* The editions "upgrader". Running `protoc --upgrade-edition` as above on a
|
||
|
file that is already in editions mode will bump it up to the latest edition
|
||
|
known to `protoc` and add features as necessary. Again, this emits a
|
||
|
features GC'd `ProtoChangeSpec`.
|
||
|
|
||
|
This is by no means all the tooling we need, but it will simplify the work of
|
||
|
robots and beavers, along with any bespoke, internal-codebase-specific tooling
|
||
|
we build.
|
||
|
|
||
|
## The OSS Story
|
||
|
|
||
|
We need to export our large-scale changes into open source to have any hope of
|
||
|
editions not splitting the ecosystem. It is impossible to do this the way we do
|
||
|
large-scale changes in our internal codebase, where we have global approvals and
|
||
|
a finite but nonzero supply of bureaucratic sticks to motivate reluctant users.
|
||
|
|
||
|
In OSS, we have neither of these things. The only stick we have is breaking
|
||
|
changes, and the only carrots we can offer are new features. There is no "global
|
||
|
approval" or "TAP" for OSS.
|
||
|
|
||
|
Our strategy must be a mixture of:
|
||
|
|
||
|
* Convincing users this is a good thing that will help us make Protobuf easier
|
||
|
to use, cheaper to deploy, and faster in production.
|
||
|
* Gently steering users to the new edition in new Protobuf definitions,
|
||
|
through protoc diagnostics (when an old edition is going or has gone out of
|
||
|
date) and developer tooling (editor integration, new-file-boilerplate
|
||
|
templates).
|
||
|
* Convincing third-party backend vendors (such as Apple, for Swift) that they
|
||
|
can leverage editions to fix mistakes. We should go out of our way to design
|
||
|
attractive migrations for them to execute.
|
||
|
* Providing Google-class tooling for migrations. This includes the large-scale
|
||
|
change tooling above, and, where possible, specialized tooling. When it is
|
||
|
not possible to provide tooling, we should provide detailed migration guides
|
||
|
that highlight the benefits.
|
||
|
* Being clear that we have a breaking changes policy and that we will
|
||
|
regularly remove old features after a pre-announced horizon, locking new
|
||
|
improvements behind completing migrations. This is a risky proposition,
|
||
|
because users may react by digging in their heels. Comms planning is
|
||
|
critical.
|
||
|
|
||
|
The common theme is comms and making it clear that these are improvements
|
||
|
everyone can benefit from, and that there is no "I" in "ecosystem": using
|
||
|
Protobuf, just like using Abseil, means accepting upgrades as a fact of life,
|
||
|
not something to be avoided.
|
||
|
|
||
|
We should lean in on lessons learned by Go (see: their `go fix` tool) and Rust
|
||
|
(see: their `rustfix` tool); Rust in particular has an editions/epoch mechanism
|
||
|
like we do; they also have feature gates, but those are not the same concept as
|
||
|
*our* features. We should also lean on the Carbon team's public messaging about
|
||
|
upgrading being a fact of life, to provide a unified Google front on the matter
|
||
|
from the view of observers.
|
||
|
|
||
|
### Prior Art: Rust Editions
|
||
|
|
||
|
The design of [Protobuf Editions](what-are-protobuf-editions.md) is directly
|
||
|
inspired by Rust's own
|
||
|
[edition system](https://doc.rust-lang.org/edition-guide/editions/index.html)[^3].
|
||
|
|
||
|
Rust defines and ships a new edition every three years, and focuses on changes
|
||
|
to the surface language that do not inhibit interop: crates of different
|
||
|
editions can always be linked together, and "edition" is a parallel ratchet to
|
||
|
the language/compiler version.
|
||
|
|
||
|
For example, keywords (like `async`) have been introduced using editions.
|
||
|
Editions have also been used to change the semantics of the borrow checker to
|
||
|
allow new programs, and to change name resolution rules to be more intuitive.
|
||
|
For Rust, an edition may require changes to existing code to be able to compile
|
||
|
again, but *only* at the point that the crate opts into the new edition, to
|
||
|
obtain some benefit from doing so.
|
||
|
|
||
|
Unlike Protobuf, Rust commits to supporting *all* past editions in perpetuity:
|
||
|
there is no ratcheting forward of the whole ecosystem. However, Rust does ship
|
||
|
with `rustfix` (runnable on Cargo projects via `cargo fix`), a tool that can
|
||
|
upgrade crates to a new edition. Edition changes are *required* to come with a
|
||
|
migration plan to enable `rustfix`.
|
||
|
|
||
|
Crates therefore have limited pressure to upgrade to the latest edition. It
|
||
|
provides better features, but because there is no EOL horizon, crates tend to
|
||
|
stay on old editions to support old compilers. For users, this is a great story,
|
||
|
and allows old code to work indefinitely. However, there is a maintenance burden
|
||
|
on the compiler that old editions and new language features (mostly) work
|
||
|
correctly together.
|
||
|
|
||
|
In Rust, macros present a challenge: rich support for interpreted, declarative
|
||
|
macros and compiled, fully procedural macros, mean that macros written for older
|
||
|
editions may not work well in crates written on newer editions, or vice versa.
|
||
|
There are mitigations for this in the compiler, but such fixes cannot be
|
||
|
perfect, so this is a source of difficulties in getting total conversion.
|
||
|
Protobuf does not have macros, but it does have rich descriptors that mirror
|
||
|
input files, and this is a potential source of problems to watch out for.
|
||
|
|
||
|
Overall, Rust's migration story is poor: they have accepted they need to support
|
||
|
old editions indefinitely, but only produce an edition every three years.
|
||
|
Protobuf plans to be much more aggressive, and we should study where Rust's
|
||
|
leniency to old versions is unavoidable and where it is an explicit design
|
||
|
choice.
|
||
|
|
||
|
## Notes
|
||
|
|
||
|
[^1]: `ctype` has baggage and I am going to ignore it for the purposes of
|
||
|
discussion. The feature is spelled `legacy_string` because adding string
|
||
|
view accessors is not likely the only thing to do, given we probably want
|
||
|
to change the mutators as well.
|
||
|
[^2]: The correct size of the horizon is arbitrary, due to the "budget phones in
|
||
|
India" problem. Realistically we would need to pick one, start the
|
||
|
migration, and halt it if we encounter problems. It is quite difficult to
|
||
|
do better than "hope" as our strategy, but `packed` is an existence proof
|
||
|
that this is not insurmountable, merely very expensive.
|
||
|
[^3]: Rust also has feature gates, used mostly so that people may start trying
|
||
|
out experimental unstable features. These are largely orthogonal to
|
||
|
editions, and tied to compiler versions. Rust's feature gates generally do
|
||
|
not change the semantics of existing programs, they just cause new
|
||
|
programs to be valid. When a feature is "stabilized", the feature flag is
|
||
|
removed. Feature flags do not participate in Rust's stability promises.
|