Recently, the Go Team took to their official blog to discuss how to make your Go Modules compatible, and to offer some advice on how to make your Go Modules compatible, which is the best practices that the team has put into practice. We stand on the shoulders of giants to write more elegant and compatible code, so let’s dig into each of these suggestions.
The Go Module will change over time as new features are added or some common parts of the Go Module are refactored.
However, releasing a new Go Module version is bad news for users. They must find a new version, learn a new API, and change its code. And some users may never update, which means you must always maintain two versions of your code. Therefore, it is usually best to change an existing Go Module in a compatible way.
In this article, we’ll explore some code tricks that will keep you compatible with the Go Module. The core idea is this: add, but don’t change or remove, your Go Module code. We’ll also discuss how to design highly compatible apis from a macro perspective.
The new function
In general, changing a function’s parameters is the most common way to break code compatibility. We’ve talked about several ways to solve this problem, but let’s first look at a bad practice.
There is a function like this:
func Run(name string)
Copy the code
When we want to extend this function for certain situations, we add the size parameter to this function:
func Run(name string, size ...int)
Copy the code
If you update something in other code, or if the user of the Go Module updates it, code like this will cause problems:
package mypkg
var runner func(string) = yourpkg.Run
Copy the code
The original Run function was of type func(string), but the new Run function is of type func(string,… Int), so an error is reported at compile time. The call method must be modified to accommodate the new function type, which causes inconvenience and even bugs for developers using the Go Module.
In this case, we can add a new function to solve the problem, rather than changing the function signature. As we all know, the context package was introduced after Golang 1.17, and usually CTX is passed as the first argument to the function. However, the existing exportable functions of the already stable API cannot change the function signature by adding context.context to the first input parameter of the function, which affects all function callers, especially in some underlying code libraries, which is a very dangerous operation.
The Go Team solved this problem by adding new functions. For example, the database/ SQL package’s Query method is always signed:
func (db *DB) Query(query string, args ...interface{}) (*Rows, error)
Copy the code
When the Context Package was introduced, the Go Team added a function like this:
func (db *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error)
Copy the code
And only one code change:
func (db *DB) Query(query string, args ...interface{}) (*Rows, error) {
return db.QueryContext(context.Background(), query, args...)
}
Copy the code
In this way, Go Team can smoothly upgrade a package without compromising code readability or compatibility. Similar code can be found everywhere in the Golang source code.
Optional arguments (optional arguments)
If you decide before you implement the package that the function may need to add parameters later to extend some functionality, you can use optional arguments in the function signature ahead of time. The easiest way to do this is to use structure parameters in the function signature. Here is crypto/ TLS. Dial from golang source code:
func Dial(network, addr string, config *Config) (*Conn, error)
Copy the code
The Dial function implements TLS handshake, which requires many other parameters and supports default values. Default values are used when passing nil to config; The default value will be overridden when the Config struct is passed. If a new TLS configuration parameter appears later, it can be easily implemented by adding a new field to the Config struct, which is backward compatible.
In some cases, new functions and the use of optional arguments can be combined by making the structure of the optional argument a receiver for the method. For example, before Go 1.11, the Listen method in the NET Package was signed:
func Listen(network, address string) (Listener, error)
Copy the code
However, in Go 1.11, Go Team added two new features:
- Passed the context parameter;
- increased
control function
Allows the caller in the network connection not yetbind
To adjust the parameters of the original connection.
This seems like a pretty big change, and if you’re a typical developer, the most you can do is add a new function, context, control function, to the parameters. But the Go Team’s developers are no ordinary people, and the net Package authors think that someday this function will be adjusted, or need more parameters? A ListenConfig structure is set aside to implement the Listen method for strcut, without having to add another function to solve the problem.
type ListenConfig struct {
Control func(network, address string, c syscall.RawConn) error
}
func (*ListenConfig) Listen(ctx context.Context, network, address string) (Listener, error)
Copy the code
Another design pattern, called optional types, takes optional functions as function parameters, each of which can adjust its state with parameters. In Rob Pike blog (commandcenter.blogspot.com/2014/01/sel…). This pattern is explained in detail in the Book. This design pattern is used extensively in the GRPC source code.
Option types serve the same purpose as option structs in function arguments: they are an extensible way to pass behavior and modify configurations. Deciding which one to choose depends largely on the situation. The DialOption option type of gRPC is used:
grpc.Dial("some-target",
grpc.WithAuthority("some-authority"),
grpc.WithMaxDelay(time.Second),
grpc.WithBlock())
Copy the code
Of course you can also implement this as a struct option:
notgrpc.Dial("some-target", ¬grpc.Options{
Authority: "some-authority",
MaxDelay: time.Minute,
Block: true,})Copy the code
Any of the above is a way to maintain Go Module compatibility, and you can choose the appropriate implementation for different scenarios.
Ensure compatibility of interfaces
Sometimes, new feature support requires changing the exposed interface: new methods are needed to extend the interface. It is not appropriate to add methods directly to the interface, which would result in code changes for all implementations of the interface. So how do we support new methods on exposed interfaces?
The Go Team suggests that you define a new interface using a new method, and then dynamically check whether the provided type is old or new wherever the old interface is used.
Let’s use the Golang source archive/tar package to illustrate. Tar.newreader takes IO.Reader, but the Go Team decided to provide a more efficient way to skip a file’s header when calling Seek. However, we cannot add Seek methods directly to IO.Reader, which affects all methods that implement IO.Reader.
Another approach is to change the input parameter of tar.NeaReader to IO.ReaderSeeker Interface, since this interface supports both IO. But as mentioned earlier, changing the signature of a function is not a good way to do it.
So the Go Team decided to leave tar.NewReader’s signature unchanged and type check in the Read method:
package tar
type Reader struct {
r io.Reader
}
func NewReader(r io.Reader) *Reader {
return &Reader{r: r}
}
func (r *Reader) Read(b []byte) (int, error) {
if rs, ok := r.r.(io.Seeker); ok {
// Use more efficient rs.Seek.
}
// Use less efficient r.r.Read.
}
Copy the code
You can follow this policy if you have a situation where you want to add methods to an existing interface. First create a new interface using a new method, or identify an existing interface using a new method. Next, determine the relevant code that needs to be added, type check the second interface, and add code that uses it.
If possible, it is best to avoid such problems. For example, when designing a constructor, it is best to return a concrete type. Unlike interfaces, using concrete types allows you to add new methods in the future without disrupting user usage, while making it easier to extend your Go Module in the future.
If you use an interface and you don’t want the user to implement it, you can add unexported methods to the interface.
type TB interface {
Error(args ...interface{})
Errorf(format string, args ...interface{})
// ...
// A private method to prevent users implementing the
// interface and so future additions to it will not
// violate Go 1 compatibility.
// private prevents the user from implementing it
private()
}
Copy the code
New Configuration Method
So far, we’ve discussed how changing a function’s signature or adding methods to an interface can affect the user’s code and cause compilation failures. In fact, changes in function behavior can cause the same problem. For example, many developers want json.decoder to ignore JSON fields that are not in structs. But when the Go Team wants to return some errors in this case, they have to be careful, because doing so can cause many users of this method to suddenly receive errors they never encountered before.
As a result, they don’t have to change all the user’s behavior, but to add a Decoder structure configuration methods: Decoder. DisallowUnknownFields. Calling this method allows the user to select the new behavior while preserving the old method for existing users.
Keep structs compatible
As we have seen above, any change to a function’s signature is a destructive change. But using structs makes your code a lot more flexible, and if you have exportable structure types, you can almost always add a field or remove an unexported field without breaking compatibility. When you add a field, make sure its zero value makes sense and preserve the old behavior so that existing code without setting the field continues to work.
Remember the ListenConfig struct that the author of the NET Package added in Go 1.11? It turns out his design was right. In Go 1.13, the KeepAlive field was added to allow the keep-alive function to be disabled or used. With the previous design, this field is much easier to add.
There’s one detail about struct use that can have a big impact on users if you don’t notice. If all fields in a struct are decidable (i.e. == or! =, or can be used as a map key), then the struct is decidable. In this case, if you add an undecidable type to a struct, that will cause the struct to become undecidable as well. If the user uses your struct in the code to perform such operations, they will get a code error.
If you want to keep a structure decidable, don’t add non-comparable fields to it. You can write test cases for this to avoid forgetting.
Conclusion
When planning an API from scratch, think carefully about how extensible the API will be in the future. And when you do need to add new features, remember these rules: Add, don’t change or remove. Keep in mind that adding interface methods, function parameters, and return values makes the Go Module backward incompatible.
If you really need to change the API drastically, or add more new features, then using a new VERSION of the API is a better way to do it. But most of the time, making backwards-compatible changes should be your first choice to avoid annoying users.
* Official information * latest technology * exclusive interpretation *Copy the code