English | 中文
In tRPC-Python, one protocol name (the built-in names are trpc and http) ties together four extension slots: server and client frame splitting (Checker/Framer) and server and client codec.
This page implements a custom line protocol that splits a message on the newline \n, and shows how those four slots attach to the framework's default TCP transport. Like every other plugin, a protocol plugin is registered when the module that defines it is imported. Import that module from the service entry before create_server (or before the client loads configuration).
One RPC moves through the following steps. The gray boxes on both sides of "network" in the figure are the codec. Frame splitting happens before the bytes enter the codec. Serialization happens between the codec and the business handler.
- Client send and receive (
_invoke_backend):- On send: follow the types on
messagethrough serialize, then compress, thencodec.encode_data. Thebodypassed toencode_datais already serialized and compressedbytes. - On receive:
checker.read_framesplits one complete response frame off the connection, thencodec.decode_data, then decompress, then deserialize.
- On send: follow the types on
- Server receive and reply (
Service.handle):- On receive: after the TCP connection delivers bytes, the
checkersplits out one complete request frame.Service.handlethen runsawait codec.decode_data, decompresses bymessage.compress_type, deserializes bymessage.serialization_type, and enters the filter chain and the business function. - On reply: serialize, then compress, then
codec.encode_data, and write the result back to the network.
- On receive: after the TCP connection delivers bytes, the
The protocol string in YAML is the registered name shared by the four slots:
- Server:
ServiceOption.set_protocol(protocol)looks up and installsget_server_codecandget_server_parserunder that name. When the service is created, if that protocol name has noSERVER_TRANSPORTof its own,create_serveruses the baseTrpcServerTransport(the framework's built-in TCP/UDP listener). - Client:
Options.set_protocol(protocol)looks upCLIENT_CODEC,CLIENT_CHECKER, andCLIENT_TRANSPORTunder that name. If no client transport of the same name is registered, the defaulttrpcclient transport stays in place.
A custom message format on top of TCP, such as line on this page, therefore only needs the four checker and codec plugins. Transport can keep using the framework's built-in TCP.
The cell labeled Framer in the figure is the abstract base class BaseServerProtocolParser (imported from trpc_sdk.abc). Implement these three methods:
feed_data(data: bytes) -> None: append newly received bytes to this connection's buffer.read() -> List[bytes]: split out every complete frame that is ready, return them, and remove them from the buffer. Return an empty list[]when no complete frame is ready.reset() -> None: clear the buffer.
Why registration goes through a factory. Each new TCP connection makes the lower protocol object (in _tcp_protocol.pyx, the dispatcher, or the executor) call checker.create() for a parser that belongs to that connection, then feed_data and read from data_received. What you register as PluginType.SERVER_CHECKER must be a function that returns a BasePluginFactory. create() on that factory hands each connection its own buffer.
feed_dataon theBaseServerProtocolParserbase class writes into an internal buffer that pure Python cannot read. A custom parser written in pure Python should keep its ownbytearrayand overridefeed_data,read, andreset.
LineServerParser below splits on b"\n". The frame keeps the trailing \n, and the codec strips it later.
from typing import List
from trpc_sdk.abc import BasePluginFactory
from trpc_sdk.abc import BaseServerProtocolParser
from trpc_sdk.abc import PluginType
from trpc_sdk.plugin import register_plugin
class LineServerParser(BaseServerProtocolParser):
"""Split a TCP stream into newline-terminated frames."""
def __init__(self) -> None:
self._buf = bytearray()
def feed_data(self, data: bytes) -> None:
self._buf.extend(data)
def read(self) -> List[bytes]:
frames = []
while True:
index = self._buf.find(b"\n")
if index < 0:
break
frames.append(bytes(self._buf[: index + 1]))
del self._buf[: index + 1]
return frames
def reset(self) -> None:
self._buf.clear()
class LineCheckerFactory(BasePluginFactory):
"""Hand out one line parser per connection."""
def create(self) -> LineServerParser:
return LineServerParser()
@register_plugin(PluginType.SERVER_CHECKER, "line")
def setup_line_checker() -> LineCheckerFactory:
return LineCheckerFactory()Compared with the built-in implementations:
trpc: The built-inTrpcServerProtocolCheckersplits frames from the fixed 16-byte header. It checks magic2352and reads the total lengthpackage_len. It does not parse the protobuf header at this stage.
http:ServerHttpCheckeronly occupies the slot. HTTP messages are parsed by the underlyingaiohttp, so they do not go through the framework's TCPdata_receivedframe-splitting path.
On the client receive path, bytes first pass the cell labeled Framer, which is BaseClientProtocolParser, then decode_data and deserialization.
Unlike the server, a client checker subclasses BaseClientProtocolParser (itself a BasePlugin) and is registered directly as a class, so the registry holds one instance. Implement async def read_frame(self, connection) -> bytes. The client TCP transport awaits that method to read one complete response frame from a pooled connection (PooledConnection).
PooledConnection.read(n) asynchronously reads exactly n bytes. The snippet below reads 1 byte at a time until b"\n", to show the idea. When the connection closes or the read fails, the exception is turned into ClientDecodeError:
from trpc_sdk.abc import BaseClientProtocolParser
from trpc_sdk.abc import PluginType
from trpc_sdk.exceptions import ClientDecodeError
from trpc_sdk.plugin import register_plugin
@register_plugin(PluginType.CLIENT_CHECKER, "line")
class LineClientParser(BaseClientProtocolParser):
"""Read one newline-terminated frame from a pooled connection."""
async def read_frame(self, connection) -> bytes:
chunks = []
while True:
try:
piece = await connection.read(1)
except Exception as err:
raise ClientDecodeError("line connection closed") from err
chunks.append(piece)
if piece == b"\n":
return b"".join(chunks)Compared with the built-in implementations:
TrpcClientParserreads the 16-byte header first. When the magic is not2352, it immediately callsconn.close(True)to drop the connection (the byte stream is no longer aligned to the next frame), then raisesClientDecodeError.ClientHttpChecker.read_frameraisesNotImplementedErrordirectly, because HTTP responses are read byaiohttp.
A codec subclasses BaseCodec and implements a pair of methods. The first argument of both is TrpcContext:
encode_data(context, body) -> bytesdecode_data(context, buffer) -> bytes
Sync and async signatures differ:
- Server
decode_datamust beasync def, becauseService.handlecalls it withawait codec.decode_data(...). - Client
decode_data, andencode_dataon both sides, are ordinary functions (def). The framework does notawaitthose three calls. Returnbytesdirectly.
The line frame is simple:
encode_dataappends one\nto the already serializedbody.decode_datastrips the trailing\nfrom the frame the checker handed over and returns thebodybytes.rstrip(b"\n")removes every trailing newline, so the originalbodyitself must not end with\n.
Server decode_data also sets two fields on message:
- The routed method name (
server_rpc_name).Service.handlelooks up the registered RPC handler withmessage.get_server_rpc_name(). The name looks like/service/method. If the service was registered withfix_rpc_name=True, theservicespan is replaced by the servicenamefrom YAML. Alineframe carries no method name, so the example below fixes it as"/trpc.app.server.Greeter/SayHello". A real protocol can put the method name in the frame header and parse it indecode_data. - The serialization type (
serialization_type). A newmessagedefaults to serialization type0, which is protobuf. To send and receive raw bytes without protobuf, callmessage.set_serialization_type(SerializationType.Noop)indecode_data.
from typing import Optional
from trpc_sdk.abc import BaseCodec
from trpc_sdk.abc import PluginType
from trpc_sdk.abc import TrpcContext
from trpc_sdk.abc import get_message
from trpc_sdk.plugin import register_plugin
from trpc_sdk.serialization import SerializationType
@register_plugin(PluginType.SERVER_CODEC, "line")
class LineServerCodec(BaseCodec):
"""Newline-terminated server codec."""
def encode_data(self, context: TrpcContext, body: Optional[bytes]) -> bytes:
return (body or b"") + b"\n"
async def decode_data(self, context: TrpcContext, buffer: Optional[bytes]) -> bytes:
message = get_message(context)
message.set_server_rpc_name("/trpc.app.server.Greeter/SayHello")
message.set_serialization_type(SerializationType.Noop)
return (buffer or b"").rstrip(b"\n")
@register_plugin(PluginType.CLIENT_CODEC, "line")
class LineClientCodec(BaseCodec):
"""Newline-terminated client codec."""
def encode_data(self, context: TrpcContext, body: Optional[bytes]) -> bytes:
return (body or b"") + b"\n"
def decode_data(self, context: TrpcContext, buffer: Optional[bytes]) -> bytes:
return (buffer or b"").rstrip(b"\n")A few more notes:
- How the HTTP codec works: The built-in
ServerHttpCodecdoes not assemble a TCP frame. It takes theaiohttprequest and response frommessage.decode_datareads the body from the request and ignoresbuffer.encode_datawrites the status code and response headers onto the response and returns the body.- Streaming codecs:
encode_stream/decode_streamonBaseCodecare for the tRPC streaming protocol. An ordinary unary protocol does not implement them.
Serialization and compression are two plugin slots independent of the protocol:
- Serialization (
PluginType.SERIALIZATION): SubclassBaseSerializerand implementserializeanddeserialize. The registered name is the integer value ofSerializationType(PB = 0,JCE = 1,JSON = 2,FlatBuffer = 3,Noop = 4). - Compression (
PluginType.COMPRESS): SubclassBaseCompressand implementcompressanddecompress. The registered name is the integer value ofCompressType. The default, no compression, isCompressType.Noop = 0.
The line example has no serialization-type field in the header, so both sides agree on SerializationType.Noop (value 4) and exchange raw bytes:
- Server:
LineServerCodec.decode_dataabove already setsSerializationType.Noop. TheNoopdeserializer writes the raw bytes into thedataattribute of atrpc_sdk.serialization.Body. - Client: Set
serialization: 4in YAML, or callOptions().set_serialization_type(SerializationType.Noop)on the call.
If the protocol still runs on the framework's standard TCP or UDP event loop, as line does, do not implement the two items below. Use them only when the plugin must take over the network read/write or the listen loop.
- Replace transport (
SERVER_TRANSPORT/CLIENT_TRANSPORT): Register a transport plugin under the same protocol name. ReplacingCLIENT_TRANSPORTto call a third-party database SDK is covered in Database Plugin. - Take over the server listen loop (
register_protocol_handle): Importregister_protocol_handle(protocol, starter)fromtrpc_sdk.serverortrpc_sdk.runtime, and call it beforeserve()forks child processes (usually at import time). After it is registered, the framework marks addresses that use this protocol as user-owned and no longer starts the built-in TCP or HTTP listener. It callsstarter(worker, index)instead, and the pluginbinds on the event loop itself. Inseparatemulti-process mode several processes bind the same address, so the socket needsSO_REUSEPORT.
Import the module that defines LineCheckerFactory, LineClientParser, LineServerCodec, and LineClientCodec from the service entry, then configure it as follows.
server:
service:
- name: trpc.app.server.Greeter
protocol: line
network: tcp
ip: 127.0.0.1
port: 8000Besides protocol: line, the client needs a target address target (ip:// is a naming-service selector; see Naming Plugin) and serialization: 4 (SerializationType.Noop):
client:
service:
- name: trpc.app.server.Greeter
protocol: line
network: tcp
target: ip://127.0.0.1:8000
serialization: 4In code, set the protocol with Options().set_protocol("line"). If the client calls set_protocol("line") before the plugin module is imported, no CLIENT_CODEC named "line" is found, set_protocol returns without doing anything, and the later call raises ClientEncodeError.


