Custom Serializers
Custom serializers allow you to define exactly how a type is serialized and deserialized. This is useful for types that require special handling, optimization, or cross-language compatibility.
When to Use Custom Serializers
- Special encoding: Types that need a specific binary format
- Third-party types: Types from external libraries that Fory doesn't handle automatically
- Optimization: When you can serialize more efficiently than the default reflection-based approach
- Cross-language compatibility: When you need precise control over the binary format for interoperability
ExtensionSerializer Interface
Custom serializers implement the ExtensionSerializer interface:
type ExtensionSerializer interface {
// WriteData serializes the value to the buffer.
// Only write the data - Fory handles type info and references.
// Use ctx.Buffer() to access the ByteBuffer.
// Use ctx.SetError() to report errors.
WriteData(ctx *WriteContext, value reflect.Value)
// ReadData deserializes the value from the buffer into the provided value.
// Only read the data - Fory handles type info and references.
// Use ctx.Buffer() to access the ByteBuffer.
// Use ctx.SetError() to report errors.
ReadData(ctx *ReadContext, value reflect.Value)
}
Basic Example
Here's a simple custom serializer for a type with an integer field:
import (
"reflect"
"github.com/apache/fory/go/fory"
)
type MyExt struct {
Id int32
}
type MyExtSerializer struct{}
func (s *MyExtSerializer) WriteData(ctx *fory.WriteContext, value reflect.Value) {
myExt := value.Interface().(MyExt)
ctx.Buffer().WriteVarint32(myExt.Id)
}
func (s *MyExtSerializer) ReadData(ctx *fory.ReadContext, value reflect.Value) {
id := ctx.Buffer().ReadVarint32(ctx.Err())
value.Set(reflect.ValueOf(MyExt{Id: id}))
}
// Register the custom serializer
f := fory.New()
err := f.RegisterExtension(MyExt{}, 100, &MyExtSerializer{})
Context Methods
The WriteContext and ReadContext provide access to serialization resources:
| Method | Description |
|---|---|
Buffer() | Returns the *ByteBuffer for reading/writing |
Err() | Returns *Error for deferred error checking |
SetError(err) | Sets an error on the context |
HasError() | Returns true if an error has been set |
TypeResolver() | Returns the type resolver for nested types |
RefResolver() | Returns the reference resolver for ref support |
ByteBuffer Methods
The ByteBuffer provides methods for reading and writing primitive types:
Writing Methods
| Method | Description |
|---|---|
WriteBool(v bool) | Write a boolean |
WriteInt8(v int8) | Write a signed 8-bit integer |
WriteInt16(v int16) | Write a signed 16-bit integer |
WriteInt32(v int32) | Write a signed 32-bit integer |
WriteInt64(v int64) | Write a signed 64-bit integer |
WriteFloat32(v float32) | Write a 32-bit float |
WriteFloat64(v float64) | Write a 64-bit float |
WriteVarint32(v int32) | Write a variable-length signed 32-bit integer |
WriteVarint64(v int64) | Write a variable-length signed 64-bit integer |
WriteBinary(data []byte) | Write raw bytes |
Reading Methods
All read methods take an *Error parameter for deferred error checking:
| Method | Description |
|---|---|
ReadBool(err *Error) bool | Read a boolean |
ReadInt8(err *Error) int8 | Read a signed 8-bit integer |
ReadInt16(err *Error) int16 | Read a signed 16-bit integer |
ReadInt32(err *Error) int32 | Read a signed 32-bit integer |
ReadInt64(err *Error) int64 | Read a signed 64-bit integer |
ReadFloat32(err *Error) float32 | Read a 32-bit float |
ReadFloat64(err *Error) float64 | Read a 64-bit float |
ReadVarint32(err *Error) int32 | Read a variable-length signed 32-bit integer |
ReadVarint64(err *Error) int64 | Read a variable-length signed 64-bit integer |
ReadBinary(length int, err *Error) []byte | Read raw bytes of specified length |
Complex Type Example
A custom serializer for a type with multiple fields:
type Point3D struct {
X, Y, Z float64
Label string
}
type Point3DSerializer struct{}
func (s *Point3DSerializer) WriteData(ctx *fory.WriteContext, value reflect.Value) {
p := value.Interface().(Point3D)
buf := ctx.Buffer()
buf.WriteFloat64(p.X)
buf.WriteFloat64(p.Y)
buf.WriteFloat64(p.Z)
// Write string as length + bytes
labelBytes := []byte(p.Label)
buf.WriteVarint32(int32(len(labelBytes)))
buf.WriteBinary(labelBytes)
}
func (s *Point3DSerializer) ReadData(ctx *fory.ReadContext, value reflect.Value) {
buf := ctx.Buffer()
err := ctx.Err()
x := buf.ReadFloat64(err)
y := buf.ReadFloat64(err)
z := buf.ReadFloat64(err)
labelLen := buf.ReadVarint32(err)
labelBytes := buf.ReadBinary(int(labelLen), err)
value.Set(reflect.ValueOf(Point3D{
X: x,
Y: y,
Z: z,
Label: string(labelBytes),
}))
}
f := fory.New()
f.RegisterExtension(Point3D{}, 101, &Point3DSerializer{})
Handling Pointers
When your type contains pointers, handle nil values explicitly:
type OptionalValue struct {
Value *int64
}
type OptionalValueSerializer struct{}
func (s *OptionalValueSerializer) WriteData(ctx *fory.WriteContext, value reflect.Value) {
ov := value.Interface().(OptionalValue)
buf := ctx.Buffer()
if ov.Value == nil {
buf.WriteBool(false) // nil flag
} else {
buf.WriteBool(true) // not nil
buf.WriteInt64(*ov.Value)
}
}
func (s *OptionalValueSerializer) ReadData(ctx *fory.ReadContext, value reflect.Value) {
buf := ctx.Buffer()
err := ctx.Err()
hasValue := buf.ReadBool(err)
if !hasValue {
value.Set(reflect.ValueOf(OptionalValue{Value: nil}))
return
}
v := buf.ReadInt64(err)
value.Set(reflect.ValueOf(OptionalValue{Value: &v}))
}
Error Handling
Use ctx.SetError() to report errors:
func (s *MySerializer) ReadData(ctx *fory.ReadContext, value reflect.Value) {
buf := ctx.Buffer()
version := buf.ReadInt8(ctx.Err())
if ctx.HasError() {
return
}
if version != 1 {
ctx.SetError(fory.DeserializationErrorf("unsupported version: %d", version))
return
}
// Continue reading...
value.Set(reflect.ValueOf(result))
}
Registration Options
Register by ID
More compact serialization, requires ID coordination across languages:
f.RegisterExtension(MyType{}, 100, &MySerializer{})
Register by Name
More flexible but more serialization cost, type name included in serialized data:
f.RegisterNamedExtension(MyType{}, "myapp.MyType", &MySerializer{})
Best Practices
- Keep it simple: Only serialize what you need
- Use variable-length integers:
WriteVarint32/WriteVarint64for integers that are often small - Handle nil explicitly: Check for nil pointers and slices
- Version your format: Consider adding a version byte for future compatibility
- Test round-trips: Always verify that
Read(Write(value)) == value - Match read/write order: Read fields in exactly the same order you write them
- Check errors: Use
ctx.HasError()after reading to handle errors gracefully - Deploy before use: Always deploy the registered serializer to all services before sending data serialized with it. If a service receives data for an unregistered serializer, deserialization will fail
Testing Custom Serializers
func TestMySerializer(t *testing.T) {
f := fory.New()
f.RegisterExtension(MyType{}, 100, &MySerializer{})
original := MyType{Field: "test"}
// Serialize
data, err := f.Serialize(original)
require.NoError(t, err)
// Deserialize
var result MyType
err = f.Deserialize(data, &result)
require.NoError(t, err)
assert.Equal(t, original, result)
}