This repository is an example of a Elixir gRPC service that uses grpcbox
, a Erlang gRPC library. There aren't many examples demonstrating how this is done, so this example is a very naive attempt to utilise grpcbox
as the main gRPC plumbing, while we can write our business/controller logic in Elixir.
- Erlang
- Elixir
Some prior knowledge on how Elixir maps to Erlang is required.
This project uses the umbrella project pattern, which allows us to house the .erl
as well as the .ex
files in one location. grpcbox
has a sibling project called grpcbox_plugin
, utilised to generate the Erlang gRPC stub files/modules. We need to refer to these modules in our Elixir configuration, typically found in config/config.exs
.
To get started we need to start an umbrella project:
$ mix new boxy --umbrella --sup
This will create the apps
directory, in which we need to create two applications:
- The Elixir application that will actually run the gRPC server via
grpcbox
. - The Erlang application that is primarily used to build the gRPC stub files.
In our apps
directory, we need to run the following:
$ mix new boxy_elixir --sup
And
$ rebar3 new app boxy_erlang
In our boxy_erlang
project, we need to define a number of dependencies in rebar.config
:
{erl_opts, [debug_info]}.
{deps, [grpcbox]}.
{grpc, [{protos, "protos"},
{gpb_opts, [{module_name_suffix, "_pb"}]}]}.
{plugins, [grpcbox_plugin]}.
{shell, [
% {config, "config/sys.config"},
{apps, [boxy_erlang]}
]}.
The grpcbox
library will be used to generate our Erlang protobuf stubs via grpcbox_plugin
which adds a rebar3 grpc gen
command.
Let's get started by generating our Erlang stubs. Provided that we have a .proto
file like this in boxy/apps/boxy_erlang/proto/
:
syntax = "proto3";
package example;
service HelloService {
rpc Hello(HelloRequest) returns (HelloResponse);
rpc Greet(GreetRequest) returns (stream GreetResponse);
}
message GreetRequest {
string name = 1;
}
message GreetResponse {
string response = 1;
}
message HelloRequest {
string name = 1;
}
message HelloResponse {
string response = 1;
}
Running rebar3 grpc gen
should generate our Erlang stubs as follows:
$ cd apps/boxy_erlang
$ rebar3 grpc gen
===> Writing /.../boxy_erlang/src/hello_world_pb.erl
This might complain about not being able to write to a
_build
directory in theboxy_erlang
directory.
Now that we have our protobuf Erlang modules, we can access it in Elixir as :hello_world_pb
. We can also utilise alias ..., as: ...
to make it more idiomatic.
In boxy_elixir
, we need to add a couple of dependencies,
# mix.exs
defp deps do
[
{:grpcbox, "~> 0.15.0"},
{:chatterbox,
git: "https://github.com/tsloughter/chatterbox.git", tag: "v0.12.0", override: true},
{:boxy_erlang, in_umbrella: true, manager: :rebar3}
]
end
grpcbox
contains the code necessary to start a gRPC server.chatterbox
is the HTTP/2 library used bygrpcbox
.boxy_erlang
is a sibling application, used to house the Erlang stub files.
Run mix deps.get
to get our dependencies. Next, we generate our configuration in the umbrella configuration that maps to the grpcbox
sys.config
configuration:
# config/config.exs
config :boxy_elixir,
client: %{
channels: [
default_channel: [
{:http, "localhost", 8080, []},
%{}
]
]
},
servers: [
%{
grpc_opts: %{
service_protos: [:hello_world_pb],
unary_interceptors: [&BoxyElixir.LoggingMiddleware.log/4],
services: %{
:"grpc.health.v1.Health" => :grpcbox_health_service,
:"example.HelloService" => BoxyElixir.HelloController
}
},
transport_opts: %{ssl: false},
listen_opts: %{
port: 8080,
ip: {0, 0, 0, 0}
},
pool_opts: %{size: 50},
server_opts: %{
header_table_size: 4096,
enable_push: 1,
max_concurrent_streams: :unlimited,
initial_window_size: 65535,
max_frame_size: 16384,
max_header_list_size: :unlimited
}
}
]
We can map the protobuf service definitions to our Elixir controller: BoxyElixir.HelloController
. This allows us to utilise Elixir code at the edge of our business logic. A controller can look something like this:
defmodule BoxyElixir.HelloController do
def hello(ctx, request) do
{:ok, %{response: "Welcome #{request.name}"}, ctx}
end
def greet(_message, stream) do
Enum.each(1..10, fn count ->
IO.inspect("sending #{count}")
:grpcbox_stream.send(%{response: "Hello #{count}"}, stream)
Process.sleep(5_000)
end)
:ok
end
end
We're still bound by the callbacks that are specified in the :grpcbox
implementation, but we can return maps and lists that represent our response types. Last by not least, we need to start the :grpcbox
supervisor in our application supervision tree:
defmodule BoxyElixir.Application do
use Application
@impl true
def start(_type, _args) do
# An application can host multiple servers, so we need to generate a child spec
# for each entry
children =
for s <- servers(),
do:
grpc_child_spec(
s.server_opts,
s.grpc_opts,
s.listen_opts,
s.pool_opts,
s.transport_opts
)
opts = [strategy: :one_for_one, name: BoxyElixir.Supervisor]
Supervisor.start_link(children, opts)
end
defp servers, do: Application.get_env(:boxy_elixir, :servers)
# A simple wrapper on top of the `:grpcbox` module to define our server child specs.
defp grpc_child_spec(server_opts, grpc_opts, listen_opts, pool_opts, transport_opts) do
:grpcbox.server_child_spec(
server_opts || %{},
grpc_opts(grpc_opts || %{}),
listen_opts || %{},
pool_opts || %{},
transport_opts || %{}
)
end
def grpc_opts(opts) do
interceptors = opts.unary_interceptors || []
Map.put(opts, :unary_interceptor, :grpcbox_chain_interceptor.unary(interceptors))
end
end
We can start the gRPC server by running the application as you normally would:
$ iex -S mix
Provided that you have a logger interceptor defined, making gRPC requests to this endpoint should result in a response.