mirror of
https://github.com/zap-proto/http.git
synced 2026-10-04 03:07:19 +00:00
452 lines
14 KiB
Go
452 lines
14 KiB
Go
// Zero-alloc ZAP-HTTP frame codec.
|
|
//
|
|
// This file is the hot-path encoder/decoder. It writes and reads the SAME
|
|
// bytes as the reference zap-proto/go Builder path (proven byte-identical by
|
|
// the golden-wire tests and TestCodec_CrossCheck), but without the per-request
|
|
// allocations that path incurred:
|
|
//
|
|
// - no *zap.Builder / *zap.ObjectBuilder per marshal, no per-field double
|
|
// copy (Builder.SetBytes did []byte(v) then append([]byte(nil), v...));
|
|
// - no map[string][]string and no JSON for the headers slot — headers stream
|
|
// straight into the frame's variable tail as length-prefixed pairs;
|
|
// - no *zap.Message per parse and no map on decode — each name and value is a
|
|
// subslice of the frame, applied straight to the fasthttp header store.
|
|
//
|
|
// Callers append into a reusable buffer (Append{Request,Response}), so after a
|
|
// connection warms up the steady-state cost is zero heap allocation.
|
|
//
|
|
// # Wire layout (see schema/http.zap)
|
|
//
|
|
// A frame is one zap-proto/go message: a 16-byte header (magic "ZAP\x00",
|
|
// version=1, flags, rootOffset=16, size) followed by the root object. The root
|
|
// object is a fixed 48-byte section (fully zero-filled) whose text/bytes fields
|
|
// are 8-byte {relOffset,length} slots; every non-empty field's bytes are
|
|
// appended to the variable tail in field-declaration order and its slot's
|
|
// relOffset is patched to point at them. An empty text/bytes field stays a
|
|
// zeroed {0,0} slot with no tail entry. This is exactly what zap.Builder emits.
|
|
|
|
package http
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"unsafe"
|
|
|
|
"github.com/valyala/fasthttp"
|
|
zap "github.com/zap-proto/go"
|
|
)
|
|
|
|
// zeroFixed is a read-only source of zero bytes for laying down a struct's
|
|
// fixed section. Sized to the largest object (48 bytes); shared read-only
|
|
// across goroutines.
|
|
var zeroFixed [reqSlotSize]byte
|
|
|
|
// Static defaults, kept as byte slices so the encoder never allocates one.
|
|
var (
|
|
bMethodGET = []byte(fasthttp.MethodGet)
|
|
bDefaultProto = []byte(defaultProto)
|
|
)
|
|
|
|
// ---- little-endian frame header ----
|
|
|
|
// appendFrameHeader writes the 16-byte ZAP message header with rootOffset=16
|
|
// (the root object begins immediately after the header, which is 8-aligned so
|
|
// the object needs no leading padding) and a placeholder size, patched by the
|
|
// caller once the frame is complete.
|
|
func appendFrameHeader(dst []byte, flags uint16) []byte {
|
|
dst = append(dst, 'Z', 'A', 'P', 0)
|
|
dst = binary.LittleEndian.AppendUint16(dst, uint16(zap.Version))
|
|
dst = binary.LittleEndian.AppendUint16(dst, flags)
|
|
dst = binary.LittleEndian.AppendUint32(dst, uint32(zap.HeaderSize)) // rootOffset
|
|
dst = binary.LittleEndian.AppendUint32(dst, 0) // size (patched)
|
|
return dst
|
|
}
|
|
|
|
// patchVarSlot patches an 8-byte {relOffset,length} slot to point at tail data
|
|
// that was already appended starting at dataStart. A zero-length field leaves
|
|
// the slot as the zeroed {0,0} null it started as (byte-identical to
|
|
// Builder.SetBytes(nil)).
|
|
func patchVarSlot(dst []byte, slot, dataStart int) []byte {
|
|
n := len(dst) - dataStart
|
|
if n == 0 {
|
|
return dst
|
|
}
|
|
binary.LittleEndian.PutUint32(dst[slot:], uint32(dataStart-slot))
|
|
binary.LittleEndian.PutUint32(dst[slot+4:], uint32(n))
|
|
return dst
|
|
}
|
|
|
|
// putVarBytes appends a non-empty field's bytes to the tail and patches its
|
|
// slot. Empty data leaves the slot null.
|
|
func putVarBytes(dst []byte, slot int, data []byte) []byte {
|
|
if len(data) == 0 {
|
|
return dst
|
|
}
|
|
start := len(dst)
|
|
dst = append(dst, data...)
|
|
return patchVarSlot(dst, slot, start)
|
|
}
|
|
|
|
// ---- request / response encoders ----
|
|
|
|
// AppendRequest appends a request frame to dst and returns the extended slice.
|
|
// Passing a reused (len-0) buffer makes the steady-state marshal zero-alloc;
|
|
// MarshalRequest is the dst==nil convenience.
|
|
func AppendRequest(dst []byte, req *fasthttp.Request) ([]byte, error) {
|
|
frameStart := len(dst)
|
|
dst = appendFrameHeader(dst, FrameRequest<<8)
|
|
obj := len(dst) // == frameStart + 16
|
|
|
|
dst = append(dst, zeroFixed[:reqSlotSize]...)
|
|
|
|
method := req.Header.Method()
|
|
if len(method) == 0 {
|
|
method = bMethodGET
|
|
}
|
|
proto := req.Header.Protocol()
|
|
if len(proto) == 0 {
|
|
proto = bDefaultProto
|
|
}
|
|
|
|
dst = putVarBytes(dst, obj+reqMethod, method)
|
|
dst = putVarBytes(dst, obj+reqTarget, req.RequestURI())
|
|
dst = putVarBytes(dst, obj+reqProto, proto)
|
|
|
|
hStart := len(dst)
|
|
skip := newTrailerSkip(&req.Header)
|
|
dst = appendRequestHeaders(dst, &req.Header, skip)
|
|
dst = patchVarSlot(dst, obj+reqHeaders, hStart)
|
|
|
|
dst = putVarBytes(dst, obj+reqBody, req.Body())
|
|
|
|
trailer, err := encodeTrailers(&req.Header)
|
|
if err != nil {
|
|
return dst, fmt.Errorf("http: encode trailer: %w", err)
|
|
}
|
|
dst = putVarBytes(dst, obj+reqTrailer, trailer)
|
|
|
|
binary.LittleEndian.PutUint32(dst[frameStart+12:], uint32(len(dst)-frameStart))
|
|
return dst, nil
|
|
}
|
|
|
|
// AppendResponse appends a response frame to dst and returns the extended
|
|
// slice. See AppendRequest.
|
|
func AppendResponse(dst []byte, resp *fasthttp.Response) ([]byte, error) {
|
|
frameStart := len(dst)
|
|
dst = appendFrameHeader(dst, FrameResponse<<8)
|
|
obj := len(dst)
|
|
|
|
dst = append(dst, zeroFixed[:respSlotSize]...)
|
|
|
|
status := resp.StatusCode()
|
|
if status == 0 {
|
|
status = fasthttp.StatusOK
|
|
}
|
|
binary.LittleEndian.PutUint16(dst[obj+respStatus:], uint16(status))
|
|
|
|
reason := resp.Header.StatusMessage()
|
|
if len(reason) == 0 {
|
|
reason = unsafeBytes(fasthttp.StatusMessage(status))
|
|
}
|
|
proto := resp.Header.Protocol()
|
|
if len(proto) == 0 {
|
|
proto = bDefaultProto
|
|
}
|
|
|
|
dst = putVarBytes(dst, obj+respReason, reason)
|
|
dst = putVarBytes(dst, obj+respProto, proto)
|
|
|
|
hStart := len(dst)
|
|
skip := newTrailerSkip(&resp.Header)
|
|
dst = appendResponseHeaders(dst, &resp.Header, skip)
|
|
dst = patchVarSlot(dst, obj+respHeaders, hStart)
|
|
|
|
dst = putVarBytes(dst, obj+respBody, resp.Body())
|
|
|
|
trailer, err := encodeTrailers(&resp.Header)
|
|
if err != nil {
|
|
return dst, fmt.Errorf("http: encode trailer: %w", err)
|
|
}
|
|
dst = putVarBytes(dst, obj+respTrailer, trailer)
|
|
|
|
binary.LittleEndian.PutUint32(dst[frameStart+12:], uint32(len(dst)-frameStart))
|
|
return dst, nil
|
|
}
|
|
|
|
// ---- frame parsing (decode) ----
|
|
|
|
// frameRoot validates a frame's header (matching zap.Parse's magic/version/size
|
|
// checks) and confirms its type, returning the root object offset and the
|
|
// message length. Bytes past size are ignored, exactly as zap.Parse slices to
|
|
// data[:size].
|
|
func frameRoot(frame []byte, wantType uint16) (root, size int, err error) {
|
|
if len(frame) < zap.HeaderSize {
|
|
return 0, 0, zap.ErrBufferTooSmall
|
|
}
|
|
if string(frame[0:4]) != zap.Magic {
|
|
return 0, 0, zap.ErrInvalidMagic
|
|
}
|
|
// Accept wire version 1 (this runtime's baseline) and 2 (the transport
|
|
// framing generation); the data segment is identical across both.
|
|
if v := binary.LittleEndian.Uint16(frame[4:6]); v != 1 && v != 2 {
|
|
return 0, 0, zap.ErrInvalidVersion
|
|
}
|
|
size = int(binary.LittleEndian.Uint32(frame[12:16]))
|
|
if size < zap.HeaderSize || size > len(frame) {
|
|
return 0, 0, zap.ErrBufferTooSmall
|
|
}
|
|
if t := binary.LittleEndian.Uint16(frame[6:8]) >> 8; t != wantType {
|
|
return 0, 0, fmt.Errorf("http: frame type %#x, want %#x", t, wantType)
|
|
}
|
|
root = int(binary.LittleEndian.Uint32(frame[8:12]))
|
|
if root < zap.HeaderSize || root >= size {
|
|
return 0, 0, zap.ErrInvalidOffset
|
|
}
|
|
return root, size, nil
|
|
}
|
|
|
|
// frameType peeks a frame's type from its flags without a full parse. Zero
|
|
// alloc — the client uses it to dispatch streamed vs single-frame responses.
|
|
func frameType(frame []byte) (uint16, bool) {
|
|
if len(frame) < zap.HeaderSize {
|
|
return 0, false
|
|
}
|
|
return binary.LittleEndian.Uint16(frame[6:8]) >> 8, true
|
|
}
|
|
|
|
// readVar reads a text/bytes field at object offset fieldOff, zero-copy.
|
|
// Mirrors zap.Object.Bytes exactly, including the malleability guard that
|
|
// rejects a target inside the wire header (absPos < HeaderSize).
|
|
func readVar(frame []byte, size, root, fieldOff int) []byte {
|
|
slot := root + fieldOff
|
|
if slot+8 > size {
|
|
return nil
|
|
}
|
|
rel := binary.LittleEndian.Uint32(frame[slot:])
|
|
if rel == 0 {
|
|
return nil
|
|
}
|
|
length := int(binary.LittleEndian.Uint32(frame[slot+4:]))
|
|
abs := slot + int(rel)
|
|
if abs < zap.HeaderSize || abs+length > size {
|
|
return nil
|
|
}
|
|
return frame[abs : abs+length]
|
|
}
|
|
|
|
// readU16 reads a uint16 scalar at object offset fieldOff.
|
|
func readU16(frame []byte, size, root, fieldOff int) uint16 {
|
|
pos := root + fieldOff
|
|
if pos+2 > size {
|
|
return 0
|
|
}
|
|
return binary.LittleEndian.Uint16(frame[pos:])
|
|
}
|
|
|
|
// ---- header encode ----
|
|
|
|
// hpair is one header name/value, both aliasing fasthttp's store; valid only
|
|
// for the duration of a single marshal call.
|
|
type hpair struct{ key, val []byte }
|
|
|
|
// hcollector is a pooled scratch slice for sorting header pairs.
|
|
type hcollector struct{ pairs []hpair }
|
|
|
|
var hcollectorPool = sync.Pool{New: func() any { return &hcollector{pairs: make([]hpair, 0, 16)} }}
|
|
|
|
// trailerSkip is the set of trailer-declared header names, which ride in the
|
|
// frame's trailer slot and must be excluded from the headers slot. nil when the
|
|
// message declares no trailers (the common case), so it costs nothing.
|
|
type trailerSkip struct{ keys [][]byte }
|
|
|
|
func newTrailerSkip(h interface{ PeekTrailerKeys() [][]byte }) *trailerSkip {
|
|
keys := h.PeekTrailerKeys()
|
|
if len(keys) == 0 {
|
|
return nil
|
|
}
|
|
return &trailerSkip{keys: keys}
|
|
}
|
|
|
|
func (s *trailerSkip) has(key []byte) bool {
|
|
if s == nil {
|
|
return false
|
|
}
|
|
for _, k := range s.keys {
|
|
if bytesEqualFold(k, key) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ---- headers ----
|
|
//
|
|
// Headers travel as a count-prefixed run of length-prefixed name/value pairs:
|
|
//
|
|
// [u32 count] then count times: [u32 nameLen][name][u32 valueLen][value]
|
|
//
|
|
// Reading one is a walk over the frame's own bytes — every name and value is a
|
|
// subslice, so a decode allocates nothing. Repeated names are simply repeated
|
|
// pairs, which is what VisitAll already yields for a multi-value header, so
|
|
// nothing has to model a list. Order is preserved as sent, and there is no
|
|
// escaping, no sort, and no grammar for two implementations to disagree about.
|
|
|
|
// appendRequestHeaders and appendResponseHeaders collect a message's headers
|
|
// and emit them as length-prefixed pairs.
|
|
//
|
|
// Two concrete functions, not one generic over an interface, and that is NOT an
|
|
// oversight to be tidied away. A generic here compiles to a shared GC-shape
|
|
// instantiation (appendHeaders[go.shape.*uint8]), so the VisitAll call goes
|
|
// through a dictionary, escape analysis can no longer prove the closure stays
|
|
// on the stack, and every encode picks up a heap allocation — measured at
|
|
// exactly 1 allocs/op, 32 B/op, on a path documented and benchmarked as
|
|
// allocation-free. Duplicating twelve lines buys back zero-alloc encode.
|
|
func appendRequestHeaders(dst []byte, h *fasthttp.RequestHeader, skip *trailerSkip) []byte {
|
|
c := hcollectorPool.Get().(*hcollector)
|
|
c.pairs = c.pairs[:0]
|
|
h.VisitAll(func(key, value []byte) {
|
|
if isFrameOwnedHeaderBytes(key) || skip.has(key) {
|
|
return
|
|
}
|
|
c.pairs = append(c.pairs, hpair{key, value})
|
|
})
|
|
dst = emitHeaders(dst, c.pairs)
|
|
hcollectorPool.Put(c)
|
|
return dst
|
|
}
|
|
|
|
func appendResponseHeaders(dst []byte, h *fasthttp.ResponseHeader, skip *trailerSkip) []byte {
|
|
c := hcollectorPool.Get().(*hcollector)
|
|
c.pairs = c.pairs[:0]
|
|
h.VisitAll(func(key, value []byte) {
|
|
if isFrameOwnedHeaderBytes(key) || skip.has(key) {
|
|
return
|
|
}
|
|
c.pairs = append(c.pairs, hpair{key, value})
|
|
})
|
|
dst = emitHeaders(dst, c.pairs)
|
|
hcollectorPool.Put(c)
|
|
return dst
|
|
}
|
|
|
|
// emitHeaders writes pairs. No pairs appends nothing, leaving a null slot.
|
|
func emitHeaders(dst []byte, pairs []hpair) []byte {
|
|
if len(pairs) == 0 {
|
|
return dst
|
|
}
|
|
dst = binary.LittleEndian.AppendUint32(dst, uint32(len(pairs)))
|
|
for _, p := range pairs {
|
|
dst = binary.LittleEndian.AppendUint32(dst, uint32(len(p.key)))
|
|
dst = append(dst, p.key...)
|
|
dst = binary.LittleEndian.AppendUint32(dst, uint32(len(p.val)))
|
|
dst = append(dst, p.val...)
|
|
}
|
|
return dst
|
|
}
|
|
|
|
// errHeaderShort reports a header block that ends mid-field.
|
|
var errHeaderShort = errors.New("http: truncated header block")
|
|
|
|
// forEachHeader calls fn for every pair in raw. Keys and values alias raw and
|
|
// must not outlive it.
|
|
func forEachHeader(raw []byte, fn func(key, val []byte)) error {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
if len(raw) < 4 {
|
|
return errHeaderShort
|
|
}
|
|
n := binary.LittleEndian.Uint32(raw)
|
|
i := 4
|
|
for ; n > 0; n-- {
|
|
key, ni, err := headerField(raw, i)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
val, ni2, err := headerField(raw, ni)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fn(key, val)
|
|
i = ni2
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// headerField reads one length-prefixed field starting at i.
|
|
func headerField(raw []byte, i int) ([]byte, int, error) {
|
|
if i+4 > len(raw) {
|
|
return nil, 0, errHeaderShort
|
|
}
|
|
n := int(binary.LittleEndian.Uint32(raw[i:]))
|
|
i += 4
|
|
if n < 0 || i+n > len(raw) {
|
|
return nil, 0, errHeaderShort
|
|
}
|
|
return raw[i : i+n], i + n, nil
|
|
}
|
|
|
|
// ---- shared helpers ----
|
|
|
|
// isFrameOwnedHeaderBytes reports whether a header is carried by the frame
|
|
// itself (Content-Length, from the body's length prefix) or by the trailer slot,
|
|
// and so is excluded from the headers slot.
|
|
//
|
|
// HOST IS NOT ONE, and used to be listed here. Nothing in the frame carries a
|
|
// host: the decoder reconstructs it from a Host HEADER, which the encoder was
|
|
// dropping. Every request arrived with an empty Host — invisible, because a
|
|
// single-brand callee never reads it.
|
|
func isFrameOwnedHeaderBytes(key []byte) bool {
|
|
return bytesEqualFold(key, strContentLength) ||
|
|
bytesEqualFold(key, strTrailer)
|
|
}
|
|
|
|
var (
|
|
strHost = []byte(fasthttp.HeaderHost)
|
|
strContentLength = []byte(fasthttp.HeaderContentLength)
|
|
strTrailer = []byte(fasthttp.HeaderTrailer)
|
|
)
|
|
|
|
// bytesEqualFold is ASCII case-insensitive byte equality (header names are
|
|
// ASCII tokens), allocation-free.
|
|
func bytesEqualFold(a, b []byte) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
ca, cb := a[i], b[i]
|
|
if ca == cb {
|
|
continue
|
|
}
|
|
if 'A' <= ca && ca <= 'Z' {
|
|
ca += 'a' - 'A'
|
|
}
|
|
if 'A' <= cb && cb <= 'Z' {
|
|
cb += 'a' - 'A'
|
|
}
|
|
if ca != cb {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// unsafeBytes returns the bytes of s without copying. The result is read-only;
|
|
// used only where the bytes are immediately copied out (append into a frame).
|
|
func unsafeBytes(s string) []byte {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return unsafe.Slice(unsafe.StringData(s), len(s))
|
|
}
|
|
|
|
// unsafeString views b as a string without copying, for read-only use.
|
|
func unsafeString(b []byte) string {
|
|
if len(b) == 0 {
|
|
return ""
|
|
}
|
|
return unsafe.String(&b[0], len(b))
|
|
}
|