Skip to content

Commit

Permalink
Use custom encoding for jmessage wrappers.
Browse files Browse the repository at this point in the history
For the parts of the JSON-RPC wrapper that are fixed, it is noticeably faster
to explicitly encode them rather than routing through json.Marshal.  We still
use json.Marshal to handle parameters.

  name                    old time/op  new time/op  delta
  EncodeMessage/1-12      1.32µs ± 0%  0.28µs ± 0%  -79.12%
  EncodeMessage/2-12      1.25µs ± 0%  0.21µs ± 0%  -83.03%
  EncodeMessage/3-12      1.25µs ± 0%  0.13µs ± 0%  -89.47%
  EncodeMessage/4-12       521ns ± 0%   415ns ± 0%  -20.46%
  EncodeMessage/batch-12  7.93µs ± 0%  1.35µs ± 0%  -82.94%

This helps the round-trip benchmarks as well:

  BENCHMARK                           BEFORE   AFTER   SPEEDUP (%)
  BenchmarkRoundTrip/C01-CTX-B-12     20270    16556   18.3
  BenchmarkRoundTrip/C01-CTX+B-12     20569    16638   19.1
  BenchmarkRoundTrip/C04-CTX-B-12     20251    16618   17.9
  BenchmarkRoundTrip/C04-CTX+B-12     20262    16504   18.5
  BenchmarkRoundTrip/C12-CTX-B-12     20188    16561   18.0
  BenchmarkRoundTrip/C12-CTX+B-12     20254    16661   17.7
  BenchmarkRoundTrip/C01+CTX-B-12     22401    18697   16.5
  BenchmarkRoundTrip/C01+CTX+B-12     22337    18977   15.0
  BenchmarkRoundTrip/C04+CTX-B-12     22248    18769   15.6
  BenchmarkRoundTrip/C04+CTX+B-12     22216    18881   15.0
  BenchmarkRoundTrip/C12+CTX-B-12     22447    18999   15.4
  BenchmarkRoundTrip/C12+CTX+B-12     22191    19111   13.9

Related changes:

- Add JSON encoding benchmarks under the tag "oldbench".
- Split JSON handling into a separate file.
  New file: json.go
  • Loading branch information
creachadair committed Oct 23, 2021
1 parent 334382b commit 6c2c662
Show file tree
Hide file tree
Showing 4 changed files with 352 additions and 235 deletions.
236 changes: 2 additions & 234 deletions base.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,35 +100,6 @@ func (r *Request) UnmarshalParams(v interface{}) error {
// If r has no parameters, it returns "".
func (r *Request) ParamString() string { return string(r.params) }

// ErrInvalidVersion is returned by ParseRequests if one or more of the
// requests in the input has a missing or invalid version marker.
var ErrInvalidVersion error = &Error{Code: code.InvalidRequest, Message: "incorrect version marker"}

// ParseRequests parses a single request or a batch of requests from JSON.
//
// If msg is syntactically valid apart from one or more of the requests having
// a missing or invalid JSON-RPC version, ParseRequests returns ErrInvalidVersion
// along with the parsed results.
func ParseRequests(msg []byte) ([]*Request, error) {
var req jmessages
if err := req.parseJSON(msg); err != nil {
return nil, err
}
var err error
out := make([]*Request, len(req))
for i, req := range req {
if req.V != Version {
err = ErrInvalidVersion
}
out[i] = &Request{
id: fixID(req.ID),
method: req.M,
params: req.P,
}
}
return out, err
}

// A Response is a response message from a server to a client.
type Response struct {
id string
Expand Down Expand Up @@ -185,12 +156,11 @@ func (r *Response) ResultString() string { return string(r.result) }

// MarshalJSON converts the response to equivalent JSON.
func (r *Response) MarshalJSON() ([]byte, error) {
return json.Marshal(&jmessage{
V: Version,
return (&jmessage{
ID: json.RawMessage(r.id),
R: r.result,
E: r.err,
})
}).toJSON()
}

// wait blocks until r is complete. It is safe to call this multiple times and
Expand Down Expand Up @@ -218,182 +188,6 @@ func (r *Response) wait() {
}
}

// jmessages is either a single protocol message or an array of protocol
// messages. This handles the decoding of batch requests in JSON-RPC 2.0.
type jmessages []*jmessage

func (j jmessages) MarshalJSON() ([]byte, error) {
if len(j) == 1 && !j[0].batch {
return json.Marshal(j[0])
}
return json.Marshal([]*jmessage(j))
}

// N.B. Not UnmarshalJSON, because json.Unmarshal checks for validity early and
// here we want to control the error that is returned.
func (j *jmessages) parseJSON(data []byte) error {
*j = (*j)[:0] // reset state

// When parsing requests, validation checks are deferred: The only immediate
// mode of failure for unmarshaling is if the request is not a valid object
// or array.
var msgs []json.RawMessage
var batch bool
if len(data) == 0 || data[0] != '[' {
msgs = append(msgs, nil)
if err := json.Unmarshal(data, &msgs[0]); err != nil {
return errInvalidRequest
}
} else if err := json.Unmarshal(data, &msgs); err != nil {
return errInvalidRequest
} else {
batch = true
}

// Now parse the individual request messages, but do not fail on errors. We
// know that the messages are intact, but validity is checked at usage.
for _, raw := range msgs {
req := new(jmessage)
req.parseJSON(raw)
req.batch = batch
*j = append(*j, req)
}
return nil
}

// jmessage is the transmission format of a protocol message.
type jmessage struct {
V string `json:"jsonrpc"` // must be Version
ID json.RawMessage `json:"id,omitempty"` // may be nil

// Fields belonging to request or notification objects
M string `json:"method,omitempty"`
P json.RawMessage `json:"params,omitempty"` // may be nil

// Fields belonging to response or error objects
E *Error `json:"error,omitempty"` // set on error
R json.RawMessage `json:"result,omitempty"` // set on success

// N.B.: In a valid protocol message, M and P are mutually exclusive with E
// and R. Specifically, if M != "" then E and R must both be unset. This is
// checked during parsing.

batch bool // this message was part of a batch
err error // if not nil, this message is invalid and err is why
}

// isValidID reports whether v is a valid JSON encoding of a request ID.
// Precondition: v is a valid JSON value, or empty.
func isValidID(v json.RawMessage) bool {
if len(v) == 0 || isNull(v) {
return true // nil or empty is OK, as is "null"
} else if v[0] == '"' || v[0] == '-' || (v[0] >= '0' && v[0] <= '9') {
return true // strings and numbers are OK

// N.B. This definition does not reject fractional numbers, although the
// spec says numeric IDs should not have fractional parts.
}
return false // anything else is garbage
}

func (j *jmessage) fail(code code.Code, msg string) {
j.err = Errorf(code, msg)
}

func (j *jmessage) parseJSON(data []byte) error {
// Unmarshal into a map so we can check for extra keys. The json.Decoder
// has DisallowUnknownFields, but fails decoding eagerly for fields that do
// not map to known tags. We want to fully parse the object so we can
// propagate the "id" in error responses, if it is set. So we have to decode
// and check the fields ourselves.

var obj map[string]json.RawMessage
if err := json.Unmarshal(data, &obj); err != nil {
j.fail(code.ParseError, "request is not a JSON object")
return j.err
}

*j = jmessage{} // reset content
var extra []string // extra field names
for key, val := range obj {
switch key {
case "jsonrpc":
if json.Unmarshal(val, &j.V) != nil {
j.fail(code.ParseError, "invalid version key")
}
case "id":
if isValidID(val) {
j.ID = val
} else {
j.fail(code.InvalidRequest, "invalid request ID")
}
case "method":
if json.Unmarshal(val, &j.M) != nil {
j.fail(code.ParseError, "invalid method name")
}
case "params":
// As a special case, reduce "null" to nil in the parameters.
// Otherwise, require per spec that val is an array or object.
if !isNull(val) {
j.P = val
}
if len(j.P) != 0 && j.P[0] != '[' && j.P[0] != '{' {
j.fail(code.InvalidRequest, "parameters must be array or object")
}
case "error":
if json.Unmarshal(val, &j.E) != nil {
j.fail(code.ParseError, "invalid error value")
}
case "result":
j.R = val
default:
extra = append(extra, key)
}
}

// Report an error if request/response fields overlap.
if j.M != "" && (j.E != nil || j.R != nil) {
j.fail(code.InvalidRequest, "mixed request and reply fields")
}

// Report an error for extraneous fields.
if j.err == nil && len(extra) != 0 {
j.err = Errorf(code.InvalidRequest, "extra fields in request").WithData(extra)
}
return nil
}

// isRequestOrNotification reports whether j is a request or notification.
func (j *jmessage) isRequestOrNotification() bool { return j.E == nil && j.R == nil && j.M != "" }

// isNotification reports whether j is a notification
func (j *jmessage) isNotification() bool { return j.isRequestOrNotification() && fixID(j.ID) == nil }

// fixID filters id, treating "null" as a synonym for an unset ID. This
// supports interoperation with JSON-RPC v1 where "null" is used as an ID for
// notifications.
func fixID(id json.RawMessage) json.RawMessage {
if !isNull(id) {
return id
}
return nil
}

// sender is the subset of channel.Channel needed to send messages.
type sender interface{ Send([]byte) error }

// receiver is the subset of channel.Channel needed to receive messages.
type receiver interface{ Recv() ([]byte, error) }

// encode marshals rsps as JSON and forwards it to the channel.
func encode(ch sender, rsps jmessages) (int, error) {
bits, err := rsps.MarshalJSON()
if err != nil {
return 0, err
}
return len(bits), ch.Send(bits)
}

// Network guesses a network type for the specified address and returns a tuple
// of that type and the address.
//
Expand Down Expand Up @@ -433,11 +227,6 @@ func isServiceName(s string) bool {
return true
}

// isNull reports whether msg is exactly the JSON "null" value.
func isNull(msg json.RawMessage) bool {
return len(msg) == 4 && msg[0] == 'n' && msg[1] == 'u' && msg[2] == 'l' && msg[3] == 'l'
}

// filterError filters an *Error value to distinguish context errors from other
// error types. If err is not a context error, it is returned unchanged.
func filterError(e *Error) error {
Expand All @@ -449,24 +238,3 @@ func filterError(e *Error) error {
}
return e
}

// strictFielder is an optional interface that can be implemented by a type to
// reject unknown fields when unmarshaling from JSON. If a type does not
// implement this interface, unknown fields are ignored.
type strictFielder interface {
DisallowUnknownFields()
}

// StrictFields wraps a value v to implement the DisallowUnknownFields method,
// requiring unknown fields to be rejected when unmarshaling from JSON.
//
// For example:
//
// var obj RequestType
// err := req.UnmarshalParams(jrpc2.StrictFields(&obj))`
//
func StrictFields(v interface{}) interface{} { return &strict{v: v} }

type strict struct{ v interface{} }

func (strict) DisallowUnknownFields() {}
2 changes: 1 addition & 1 deletion client.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ func (c *Client) send(ctx context.Context, reqs jmessages) ([]*Response, error)
// Marshal and prepare responses outside the lock. This may wind up being
// wasted work if there is already a failure, but in that case we're already
// on a closing path.
b, err := reqs.MarshalJSON()
b, err := reqs.toJSON()
if err != nil {
return nil, Errorf(code.InternalError, "marshaling request failed: %v", err)
}
Expand Down
Loading

0 comments on commit 6c2c662

Please sign in to comment.