Skip to content

Go Reflect: Fail to Set Argument Value During Conversion

This blog records the failure when trying to set the response value using reflect to provide an adaptor for wrapping the original PRC interceptor to the custom interceptor format defined by our tech products.

The difference between them is the custom interceptor moves the response from an argument to a response. This blog introduces the challenges encountered while designing an adaptor.

Signatures of Original RPC Interceptor And Custom Interceptor
// package rpc
// original RPC interceptor signature
type Interceptor func(ctx context.Context, 
    request, response interface{}, processor Processor) uint32
type Processor func(ctx context.Context, request, response interface{}) uint32


// package custom
// interceptor in the tech team
type CustomInterceptor interface {
    Wrap(HandlerFn) HandlerFn
}
type HandlerFn func(ctx context.Context, req interface{}) (interface{}, error)

As the RPC framework requires to register services for commands, the original RPC interceptors receive interfaces with underlying concrete pointers to empty(default) structures, which is an inconsistent behavior from the origin and the custom signature.

The challenge of this adaptor is the underlying type and value of response interface:

  • Pre-processor stage: the response type is lost as the HandlerFn truncates the response type.
  • Post-processor stage: the response value should be set automatically by the adaptor.

However, both goals listed above failed because of the limitations of the current system design and reflect. This blog will record more details about the failures.

Analyzing Challenges

Given Up for Pre-Processor Stage

As the preface introduces the challenges, the custom framework signature cuts the response type/value off from the beginning. As a result, there is no way for the adaptor to handle such loss. Hence, we have no way but to give up and assume users will try to use the response type/value when they wrap their original interceptors by the adaptor.

Struggling in Post-Processor Stage

The challenge in the adaptor for the post-processor stage is that the response in the original interceptor should be set correctly by the adaptor called by itself. For a better understanding, what the adaptor should do is revealed by the pseudocode below.

func originalInterceptorToBeWrapped(ctx context.Context,
    request, response any, processor Processor) uint32 {
    // pre-processor stage:
    // if it's wrapped by the adaptor, the response is lost
    // because the custom framework truncates the value passed by RPC framework
    code := processor(ctx, request, response)

    // post-processor stage
    // the processor executes the real handler and get the response value
    // however, the processor MUST do some stuffs for the response
    // otherwise, it won't be changed ,
    // even the real response is got inside the processor
    return code
}
Hence, the processor, which is provided by the adaptor to the originalInterceptorToBeWrapped, should do something to set the response correctly. The idea is feasible now. However, as we will see later, due to the limitation of the reflect, it cannot be achieved technically in Go.

Implementation to Set Value

Following the idea mentioned above, we need to set the response value inside the adaptor. Hence, we need to construct a pointer to an interface so the adaptor could set the value inside it. The implementation looks like this:

type ServerAdapter rpc.Interceptor

func (sa ServerAdapter) Wrap(
    handlerFunc custom.CustomInterceptor) custom.CustomInterceptor {

    processor := func(ctx context.Context, request, response any) uint32 {
        realResp, err := handlerFunc(ctx, request)

        // response is an empty interface passed by the adapter
        // or an interface with an interface pointer
        // spiResp is the interface with concrete underlying type
        // we set response to pass the spiResp value and type into response
        original := reflect.ValueOf(response)
        original.Elem().Set(reflect.ValueOf(realResp))

        if err != nil {
            return convertErrToCode(err)
        }
        return 0
    }

    return func(ctx context.Context, req any) (any, error) {
        var resp any
        code := sa(ctx, req, &resp, processor)
        err := convertCodeToErr(code)
        return resp, err
    }
}
In the processor, we can set the response, which is passed from outside as a pointer, with the actual response value.

Error: Different Types Between Expected and Actual Types

As the implementation reveals, the response value could be set. However, it isn't set up correctly.

Hence, even though the final values(I mean, let's skip the pointers wrapped by interfaces) are the same, we will get a response with the wrong type compared to the expectation.

For example, illustrating with the response type EchoResponse:

type EchoResponse struct {
    Data             *string `protobuf:"bytes,2,opt,name=data" json:"data"`
}
The actual and expected values are quite different, as the diff shows below:
-- // expected value:
-- interface { *EchoResponse }
++ // actual value:
++ interface { *interface { *EchoResponse } }

It's caused that once we pass the &resp into function sa, it will immediately be casted interface { *interface {nil} }.

        var resp interface{}
        code := sa(ctx, req, &resp, processor)

The casting fails the further setting up by reflect. Because Go passes all arguments by values, its type is interface { *interface { nil } } when we want to modify the response.

    processor := func(ctx context.Context, 
        request, response interface{}) uint32 {

        // response has a type of `interface { *interface { nil } }`
        original := reflect.ValueOf(response)
        original.Elem().Set(reflect.ValueOf(realResp))
        // ignore some lines
    }
As a result, for reflection, we can only change the place the pointer points to. That's why we can only set the response to interface { *interface { *EchoResponse } }, instead of the expectation value interface { *EchoResponse }.

Conclusion

Go interface will wrap a pointer to a new interface as its underlying pointer. Moreover, the interfaces are passed by values. Hence, the underlying pointers are non-settable by reflect as they are values.