`ByteToMessageDecoder`, but with no `ByteBuffer`

ByteBuffer is a longstanding problem in several of our codebases, because we need to pass the type everywhere (in order to avoid allocating and copying to [UInt8]) and this implies that SwiftNIO must become a universal dependency, even in components that have nothing to do with networking. therefore we’ve been working hard to remove ByteBuffer from as many places in our code as possible, and replacing it with a standard [UInt8] storage.

one obstacle we have run into during this refactor is ByteToMessageDecoder. the wire protocol logic looks a bit like:

extension SomeWireMessageDecoder:ByteToMessageDecoder
{
    public mutating
    func decode(context:ChannelHandlerContext, buffer:inout ByteBuffer) throws -> DecodingState
    {
        let header:MessageHeader
        if let seen:MessageHeader = self.header
        {
            header = seen
        }
        else if MessageHeader.size <= buffer.readableBytes
        {
            header = try .parse(from: &buffer)
        }
        else
        {
            return .needMoreData
        }

        guard header.count <= buffer.readableBytes
        else
        {
            self.header = header
            return .needMoreData
        }

        self.header = nil
        let message:MessageBody<ByteBufferView> = try .parse(from: &buffer, 
            length: header.count)

        context.fireChannelRead(self.wrapInboundOut(message))
        return .continue
    }
}

this code relies on SwiftNIO to accumulate the raw data into a ByteBuffer instead of a [UInt8], which i understand is sometimes desirable because ByteBuffer can reallocate in-place while [UInt8] always needs a new allocation on resize. but in this case, we already know the length of the expected message from reading the header, and we are just wrapping a view of the buffer in a MessageBody<some RandomAccessCollection>, so ByteBuffer doesn’t provide any benefits over [UInt8].

how difficult would it be to create an analogue to ByteToMessageDecoder that accumulates into a [UInt8] with a size hint instead of ByteBuffer?

On [UInt8] you'd see pretty poor performance because reading off bytes at the front requires linear time. But if you're okay with ArraySlice<UInt8> then that's totally possible.

You could copy NIOCore's Codec.swift and adapt the code to work on ArraySlice rather than ByteBuffer. This won't be too bad at all.

You can even continue to use this with SwiftNIO pipelines if you added another handler that converted ByteBuffers into ArraySlices by doing Array(buffer: incomingByteBuffer)[...].

2 Likes

Isn't the current suggested best practice to use AsyncSequence<ArraySlice<UInt8>>?

Well, there are two separate concerns in play here:

  1. How are the (chunks of) bytes or other values arriving over time? That could be a NIO Channel with ByteBuffers, it could be an AsyncSequence<Array<UInt8>> or something entirely different
  2. A synchronous and deterministic mechanism to decode the stream of bytes into messages.

NIO's ByteToMessageDecoder does the decoding bit (2) and AsyncSequence is a mechanism for the values over time (1).

Regardless of what mechanism you use to transport bytes over time, you'll have to decode them. And that's often done with an accumulation & decoding loop. NIO comes with two of those accumulation & decoding loop implementations:

My understanding of the OP's question was that they're looking for something like a ByteToMessageHandler/ByteToMessageDecoder or NIOSingleStepByteToMessageProcessor/NIOSIngleStepByteToMessageDecoder but doing the accumulation on Array(Slice)<UInt8> rather than ByteBuffer.

correct. the goal is to produce a buffer that can be passed to a library (like a BSON decoder) that has no relationship with SwiftNIO.

if ByteBuffer were part of the standard library, it would be totally fine to accumulate into ByteBuffer, but it’s not. so ArraySlice<UInt8> it is.