The Crystal Programming Language Forum

Dynamic method dispatch for gRPC

I’m building a gRPC library and trying to figure out how to route strings from the request to an actual method dispatch. Here’s an example of what I’m working with:

abstract class CatalogService
  include Protobuf::Service 

  # rpc GetProduct (ProductRequest) returns (ProductResponse) {}
  rpc GetProduct, ProductRequest, ProductResponse

  # rpc SearchProducts (ProductSearchRequest) returns (ProductsResponse) {}
  rpc SearchProducts, ProductSearchRequest, ProductsResponse
end

Given this code, I need some way of getting the string ”GetProduct” to call the get_product method. I’m thinking the result code might need something along the lines of this:

def handle(method, request_body)
  case method
  when “GetProduct”
    get_product(ProductRequest.from_protobuf(request_body)
  when “SearchProducts”
    search_products(ProductSearchRequest.from_protobuf(request_body)
  # ...
  end
end

But I haven’t figured out a way to do that with individual calls to the rpc macro. Do I need to combine my calls to rpc into a single macro invocation?

It’s possible. Here’s one way:

module Protobuf::Service
  macro included
    RPCS = [] of Nil
  end

  macro rpc(name, input, output)
    {% RPCS << [name.id, input.id, output.id] %}
  end

  def handle(method, request_body)
    {% begin %}
      case method
        {% for rpc in @type.constant("RPCS") %}
        when {{rpc[0].stringify}}
          {{rpc[0].underscore}}({{rpc[1]}}.from_protobuf(request_body))
        {% end %}
      end
    {% end %}
  end
end

class CatalogService
  include Protobuf::Service

  # rpc GetProduct (ProductRequest) returns (ProductResponse) {}
  rpc GetProduct, ProductRequest, ProductResponse

  # rpc SearchProducts (ProductSearchRequest) returns (ProductsResponse) {}
  rpc SearchProducts, ProductSearchRequest, ProductsResponse
end

service = CatalogService.new
service.handle("GetProduct", "some_body")

The idea is to define an RCPS constant that in each included type that, at compile time, accumulates stuff on each rpc macro call. We always have to type the array but we use [] of Nil as a way to say “we aren’t really going to use this at runtime, just at compile time”. It’s a bit hacky, maybe, maybe not.

Then we define a handle method that traverses that RCPS array that we accumulated. We can’t use RPCS directly because constants are looked up on the class that defines the method, not subtypes, but we can use @type.constant("RPCS") to get it on the actual type. Another way would be to also define handle in the included hook but then we have to double escape.

1 Like

My take on routing is that the mapping of string to methods should be done either as a convention or in a way the compiler checks if the string will be resolved to a method.

In https://github.com/bcardiff/crystal-routing I dropped a way to do that in a basic way. For both http or plain strings scenarios.

2 Likes

Ah, the macro constant. Excellent idea! I was trying all kinds of things last night:

  • Defining handle inside a macro finished
    • This didn’t seem to work at all, but I suppose that kinda makes sense since I guess all the methods are supposed to have been defined already?
    • This is the first time I’ve ever used this hook so I don’t quite understand the mechanics
  • Annotations (which might actually not be a bad idea, but not the idea)
  • A bunch of other things I can’t quite remember right now
  • Crying

Oh snap, I think this might be an easy way to get what I want, based on where the code is now. I keep forgetting about previous_def.

Sure enough, this worked:

module Protobuf
  module Service
    # ...

    macro included
      def handle(method_name : String, request_body : IO)
        raise InvalidMethodName.new("Unknown RPC method {{@type.id}}/#{method_name}")
      end

      macro rpc(name, receives, returns)
        abstract def \{{name.stringify.underscore.id}}(request : \{{receives}}) : \{{returns}}

        def handle(method_name : String, request_body : IO)
          if method_name == \{{name.stringify}}
            \{{name.stringify.underscore.id}}(\{{receives}}.from_protobuf(request_body))
          else
            previous_def(method_name, request_body)
          end
        end
      end
    end
  end
end

The nested macro was primarily to mention @type in the exception. I’m not sure that’ll be necessary, though, so I might be able to clean it up a bit.