Revamping the quic-go API: Transitioning from Interfaces to Structs
In the latest quic-go release, v0.53.0, we’ve made significant changes to the quic-go API. This is the most significant overhaul of the quic-go API in years, and unfortunately, requires some code changes when users upgrade their quic-go dependency.
These changes make the quic-go API more idiomatic and future-proof, justifying the one-time inconvenience for downstream users. We’ve adopted Go’s principle of accepting interfaces and returning structs across the entire quic-go API. In Go, interfaces are implicitly implemented: a struct automatically satisfies an interface if it possesses all required methods, without needing explicit declarations. This allows consumers to define custom interfaces that include only the methods they need.
quic-go Connection API
When designing a QUIC API, the most basic function is establishing a QUIC connection. For example, DialAddr
dials a QUIC connection to a remote server:
DialAddr(ctx context.Context, addr string, tlsConf *tls.Config, conf *Config) (Connection, error)
The function parameters of DialAddr
are less relevant here, we’ll focus our discussion on the return value. Before v0.53.0, DialAddr
returned a Connection
interface. This interface captured a large number of methods:
type Connection interface {
AcceptStream(context.Context) (Stream, error)
AcceptUniStream(context.Context) (ReceiveStream, error)
OpenStream() (Stream, error)
OpenStreamSync(context.Context) (Stream, error)
OpenUniStream() (SendStream, error)
OpenUniStreamSync(context.Context) (SendStream, error)
LocalAddr() net.Addr
RemoteAddr() net.Addr
CloseWithError(ApplicationErrorCode, string) error
Context() context.Context
ConnectionState() ConnectionState
SendDatagram([]byte) error
ReceiveDatagram(context.Context) ([]byte, error)
AddPath(*Transport) (*Path, error)
}
Over the years, this interface has accumulated more and more methods: When we implemented support for QUIC Datagrams (RFC 9221), we added SendDatagram
and ReceiveDatagram
to send and receive datagrams. When we added support for QUIC Connection Migration, we added AddPath
to allow switching between different network paths.
Adding a new method to an interface is a breaking change for users of the interface, as existing implementations of the interface no longer satisfy the new version of the interface.
As a rule of thumb, the fewer methods a Go interface has, the more useful it is. Think of the ubiquitous io.Reader
, io.Writer
or the http.RoundTripper
, which all are comprised of a single method.
The better design for the QUIC connection API is to return a struct instead of an interface, and this is what we’ve done in v0.53.0. DialAddr
now returns a Conn
struct:
DialAddr(ctx context.Context, addr string, tlsConf *tls.Config, conf *Config) (*Conn, error)
The Conn
struct is a concrete type. It implements the methods that were previously defined on the Connection
interface, and there’s no change in how these methods are used by consumers. The Connection
interface was entirely removed - it is not needed anymore. Consumers of the quic-go API are free to define their own interface that captures the methods of Conn
that they are interested in, or they can use Conn
directly.
This is a much more future-proof design, as it allows us to add new methods to Conn
in the future without worrying about polluting the Connection
interface.
As you might have noticed, we decided to rename Connection
to Conn
, for two reasons: First of all, this aligns better with standard library naming conventions (think of the net.Conn
, net.PacketConn
, net.UDPConn
, etc.). Secondly, it will lead to compilation errors when users upgrade to the new version of quic-go. In this case, this is a good thing, as it signals to users that their code needs to be updated.
quic-go Stream API
Once a QUIC connection is established, the next step is to open or accept QUIC streams. There are multiple types of QUIC streams: Bidirectional streams allow sending and receiving of data, whereas unidirectional streams only allow data transmission in a single direction. Consequently, we used to have a SendStream
and ReceiveStream
interface for unidirectional streams, and a Stream
interface (which embedded the SendStream
and ReceiveStream
interface) for bidirectional streams.
In v0.53.0, these three interface were replaced with concrete types: Stream
, SendStream
and ReceiveStream
are now structs, not interfaces.
This will allow us to add new methods to these structs in the future without breaking existing implementations of the interface, and we’re planning to make use of this really soon: Work is ongoing to implement the QUIC Stream Resets with Partial Delivery IETF draft, which require the addition of an additional method to the SendStream
struct.
HTTP/3 API
Similarly, we updated the HTTP/3 package’s API, replacing interfaces with structs. Users who exclusively use HTTP/3 for standard HTTP requests will likely be unaffected by this change, as quic-go seemlessly integrates with the standard library’s net/http
package.
For advanced use cases, such as WebTransport and the various MASQUE proxying protocols, we released new versions of webtransport-go and masque-go that adopt the new API. At this point, we regard the advanced HTTP/3 API as less mature as the connection and stream API on the QUIC layer, and might introduce further changes in the future, as the WebTransport and MASQUE implementations evolve.
The quic-go project and the QUIC Interop Runner are community-funded projects.
If you find my work useful, please considering sponsoring: