Closed HelloGrayson closed 8 years ago
ResMeta needs to carry a Context.
@kriskowal that's an aside, but yup, agreed.
I'm in favor of per-encoding *Meta
objects because, it gives us more flexibility around per-encoding metadata. Besides Thrift/Protobuf having fewer fields in the ReqMeta, we have the possibility of having more fields in the ResMeta for encodings that don't have a standard concept of application errors (JSON/Raw).
For example, you could imagine having an IsApplicationError bool
field on ResMeta
for JSON and Raw which would let users indicate that their payload contains an application error (which the other side needs to know how to decipher). We would not want this field available in Thrift ResMeta
because the IDL defines the application errors.
func MyHandler(req *json.ReqMeta, body *GetValueRequest) (interface{}, *json.ResMeta, error) {
...
if ... {
return &CustomResponseShape{Error: "stuff"}, &json.ResMeta{ApplicationError: true}, nil
}
return &CustomResponseShape{Success: result}, nil, nil
}
Combining these Meta objects puts us in a tough spot when we need to add fields that other encodings don't need. I don't like that the contract will become "leave this as zero value if you think you don't need to specify this."
This can also increase user confusion: When people see the godocs for the combined Meta objects, they'll see these fields that they don't need to fill if they're using Thrift/gRPC with YARPC. If they do, we'll overwrite them (or worse, use what they gave and end up making an invalid request), which IMO is an API design smell in itself. We'll have this jank-sounding documentation: "Don't fill these fields if you're using Thrift because we'll overwrite them."
I don't think it's very confusing to have per-encoding meta objects. It's surprising the first time you see it because it's easy to assume that there's a single Meta object, but after that it's fairly straightforward. You're already doing, $encoding.New(..)
to build clients for a specific encoding, so there's nothing too confusing about using $encoding.Foo{..}
to build objects that those clients rely on.
Disclaimer: I don't have a very strong opinion, and don't know enough details about yarpc architecture.
One thing that strikes me as odd as an end user is the leak of abstraction when encoding have to be considered in client/handler code. One big promise of yarpc is that the end user code does not need to think about the transport. That's a great abstraction. But another abstraction can be the encoding. This abstraction is already partially implied by the example
func MyHandler(req *json.ReqMeta, body *GetValueRequest) (interface{}, *json.ResMeta, error)
The handler takes body *GetValueRequest
argument, which, I recon, is a domain class defined by the user, devoid of encoding concerns. So it would be nice if the whole signature was encoding-neutral. That way the business code can be written once, and both transport and encoding concerns are addressed by configuration.
Again, as I haven't been involved in the design, I don't know if what I'm saying is doable, but I think it would be a nice level of abstraction. On the other hand, if the encoding cannot be abstracted away from the handler signature, the body
might as well be json.Message
.
My 2c.
@yurishkuro yep, exactly. This is RPC so all I care about is getting my call from here to there.
So a few thoughts here:
Okay, I think I'm convinced that a single meta object is better. I prototyped a new API based on the discussion above. I'm half way through implementing it. I figured now is a good time to get feedback.
We now have Re{q,s}Meta
interfaces with constructors for both of them in the
yarpc
package. Both interfaces support fluid setters because users will need
to provide these objects at all call sites.
package yarpc
type ReqMeta interface {
Context() context.Context
Headers() Headers
Procedure() string
SetHeaders(Headers) ReqMeta
SetProcedure(string) ReqMeta
}
func NewReqMeta(context.Context) ReqMeta
type ResMeta interface {
Context() context.Context
Headers() Headers
SetHeaders(Headers) ResMeta
}
func NewResMeta(context.Context) ResMeta
(Note: ResMeta
has context attached to it for backwards context propagation.)
Usage looks like this,
// JSON
var res GetValueResponse
resMeta, err := jsonClient.Call(
yarpc.NewReqMeta(ctx).
SetProcedure("getValue").
SetHeaders(yarpc.NewHeaders().With("foo", "bar")),
&GetValueRequest{...},
&res,
)
// Thrift
resBody, resMeta, err := keyValueClient.Call(
yarpc.NewReqMeta(ctx).
SetHeaders(yarpc.NewHeaders().With("foo", "bar")),
&GetValueRequest{...},
)
// Handler implementation in both JSON and Thrift will look similar
func GetValue(reqMeta yarpc.ReqMeta, body *GetValueRequest) (*GetValueResponse, yarpc.ResMeta, error) {
res := doStuff(reqMeta.Headers(), body)
return res, yarpc.NewResMeta(reqMeta.Context()).SetHeaders(...), nil
}
Worth noting: For the majority case where headers won't be provided,
yarpc.NewReqMeta(ctx)
is certainly shorter than the encoding-specific types:
yarpc.NewReqMeta(ctx)
&json.ReqMeta{Context: ctx}
&thrift.ReqMeta{Context: ctx}
On top of that, based on @shawnburke's suggestion: I plan on splitting the
Re{q,s}Meta
objects based on the direction they are traveling in. This will
allow us to stick more information on inbound ReqMeta
object.
Something like,
// Write-only interface to provide the necessary information for an outgoing
// request.
type ReqMetaOut interface {
SetProcedure(string) ReqMetaOut
SetHeaders(Headers) ReqMetaOut
}
func NewReqMetaOut(ctx context.Context) ReqMetaOut
// Read-only interface to read information about an incoming request.
type ReqMeta interface {
Caller() string
Context() context.Context
Encoding() transport.Encoding
Headers() Headers
Procedure() string
Service() string
}
// Write-only interface to provide information for an outgoing response.
type ResMetaOut interface {
SetHeaders(Headers) ResMetaOut
}
func NewResMetaOut(ctx context.Context) ResMetaOut
// Read-only interface to read information about an incoming response.
type ResMeta interface {
Context() context.Context
Headers() Headers
}
Usage will look almost the same as above except the NewRe{q,s}MetaOut
constructor will be used.
// JSON
var res GetValueResponse
resMeta, err := jsonClient.Call(
yarpc.NewReqMetaOut(ctx).
SetProcedure("getValue").
SetHeaders(yarpc.NewHeaders().With("foo", "bar")),
&GetValueRequest{...},
&res,
)
// Thrift
resBody, resMeta, err := keyValueClient.Call(
yarpc.NewReqMetaOut(ctx).
SetHeaders(yarpc.NewHeaders().With("foo", "bar")),
&GetValueRequest{...},
)
// Handler implementation in both JSON and Thrift will look similar
func GetValue(reqMeta yarpc.ReqMeta, body *GetValueRequest) (*GetValueResponse, yarpc.ResMeta, error) {
res := doStuff(reqMeta.Caller(), reqMeta.Headers(), body)
return res, yarpc.NewResMetaOut(reqMeta.Context()).SetHeaders(...), nil
}
Inbound Re{q,s}Meta
objects are named just that rather than appending In
to
their names because these are part of the handler definitions that users write.
The outbound versions will usually be constructed at the call site using the
NewRe{q,s}MetaOut
constructors so they can afford to have Out
in their
name.
We will not provide constructors for the inbound Re{q,s}Meta
types because
those will be implemented by the transport as-needed. We may offer an
implementation for testing purposes in a yarpctest
package, though.
Note: Some of this API may be revised based on what we need for backwards context propagation.
Thoughts? @yarpc/yarpc @shawnburke @yurishkuro
Starting to look really good.
reqMeta
instead of meta
for the param name in the handler, we want to be careful about abbreviating this term - others will adopt the same pattern and soon the term won't be clear in conversation.yarpc.NewReqMeta()
and yarpc.NewResMeta()
to just yarpc.ReqMeta()
and yarpc.ResMeta()
, respectively. (@prashantv can you weigh in here?). This bends the Go constructor naming idiom a bit, but these calls will be made so many times that I'd like to see calls like so:resBody, resMeta, err := keyValueClient.Get(yarpc.ReqMeta(ctx), &GetValueRequest{...})
ReqMetaOut
, since these calls will far outweigh handlers. ReqMetaIn
in the handler isn't terrible. I still think we should do this change later after living with single top-level req/res metas.reqMeta
as the parameter name.ReqMeta
will require choosing a different name for the interface. They're in the same namespace. I'm happy to do this if we're okay with users consuming yarpc.ReqMetaIn
or similar in their handlers.Actually, the more I think about it, the more the splitting makes sense. It won't be a cheap change to perform later. Also I realized we can simplify the interface further with the split by removing the Set
from the function names:
type ReqMetaOut interface {
Procedure(string) ReqMetaOut
Headers(Headers) ReqMetaOut
}
type ReqMetaIn interface {
Caller() string
Context() context.Context
Encoding() transport.Encoding
Headers() Headers
Procedure() string
Service() string
}
With the other changes you requested, usage could become,
var res GetValueResponse
resMeta, err := jsonClient.Call(
yarpc.ReqMeta(ctx).
Procedure("getValue").
Headers(yarpc.NewHeaders().With("foo", "bar")),
&GetValueRequest{...},
&res,
)
<3
Currently, we have 3 different ReqMetas:
raw.ReqMeta
json.ReqMeta
thrift.ReqMeta
The only difference between the 3 is that
thrift.ReqMeta
does not have aProcedure
field, since we determine the procedure name based on the idl method they are calling. We chose this path because, in theory, each encoding might have different request metadata. This also makes it really clear that you don't need to setProcedure
when making Thrift calls.I'd like to reconsider that position. Maintaining a separate
ReqMeta
object per encoding adds mental overhead, makes it a bit awkward to add additional encodings, and is not the most obvious experience. We've received feedback that this is a bit surprising.Instead, I'd like to see a single top-level
yarpc.ReqMeta
andyarpc.ResMeta
structs:This produces an experience like so:
ReqMeta
, but has varying request bodiesResMeta
, but has varying response bodies