ElixirとgRPCで異言語間通信

# １．はじめに

ElixirでgRPCを使う機会があったので、基本的な手順をまとめてみました。
今回は、Elixir対Python間での通信を試してみます。

# ２．準備

```sh:ターミナル
$ cd （各自の作業ディレクトリに移動）
$ mkdir grpchello
$ cd !$
```

※今回の試行では、下記のようなディレクトリ構成を想定しています。

```text
<working dir>
  + grpchello
      + hello.proto    protoファイル
      + exgrpc         Elixir版プロジェクト
          + lib        Elixir版コード
      + pygrpc         Python版プロジェクト
          + pygrpc     Python版コード
```

protobuf-compilerをインストールしておきます。

```sh:ターミナル
$ sudo apt install protobuf-compiler -y
```


# ３．プロトコルバッファ

下記の構造体でメッセージをやり取りします。

|順番|型|変数名|
|:--|:--|:--|
|1|string|message|
|2|int32|value|

protoファイルを作成します。

```sh:ターミナル
$ touch helloworld.proto
```

```protobuf:grpchello/hello.proto
syntax = "proto3";

package hello;

// エンドポイント定義
service HelloService {
  rpc SayHello (HelloRequest) returns (HelloResponse);
}

// 型定義・リクエスト
message HelloRequest {
    // 文字列
    string message = 1;    
    // 整数
    int32 value = 2;    
}

// 型定義・レスポンス
message HelloResponse {
    string message = 1;
    int32 value = 2;    
}
```


# ４．Elixir→Python間

本章では、Elixir側：クライアント、Python側：サーバーの挙動を試行します。

|役割|Elixir|Python|TCP/IPポート|
|:--|:--|:--|:--|
|クライアント|〇|  |5001|
|サーバー   |  |〇|（同上）|

## （１）クライアント・Elixir側

プロジェクトを作成します。
（あとでsupervisorも使うので、`--sup`オプションも付加します）

```sh:ターミナル
$ mix new exgrpc --sup && cd exgrpc
```

ライブラリを追加します。

```elixir:grpchello/exgrpc/mix.exs
・・・（省略）・・・
  # Run "mix help deps" to learn about dependencies.
  defp deps do
    [
      # {:dep_from_hexpm, "~> 0.3.0"},
      # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}

      # ↓追記
      {:grpc, "~> 0.7.0"},
      {:protobuf, "~> 0.12.0"}
    ]
  end
・・・（省略）・・・
```

ライブラリの依存関係を解決します。

```sh:ターミナル
$ mix deps.get
```

protoファイルを解析してElixirのコードを生成するツールをインストールします。

```sh:ターミナル
$ mix escript.install hex protobuf
Resolving Hex dependencies...
・・・（省略）・・・
* creating /home/username/.asdf/installs/elixir/1.15.5-otp-25/.mix/escripts/protoc-gen-elixir
```

protoファイルを解析してElixirのコードを生成します。

```sh:ターミナル
# 先ほどのツールが使えるようにパスを通す
$ export PATH=/home/username/.asdf/installs/elixir/1.15.5-otp-25/.mix/escripts/:$PATH

# ひとつ上のディレクトリのprotoファイルから、Elixir用のgRPCコードを生成
$ protoc --elixir_out=plugins=grpc:./lib -I../ ../hello.proto

$ ls ./lib/
exgrpc.ex  hello.pb.ex
```

:::note warn
`protoc`コマンド実行時のエラー例

**パスの通し忘れ**

```text
protoc-gen-elixir: program not found or is not executable
Please specify a program using absolute path or make sure the program is available in your PATH system variable
--elixir_out: protoc-gen-elixir: Plugin failed with status code 1.
```

**オプション`-I`の指定忘れ**

```text
../pygrpc/proto/helloworld.proto: File does not reside within any path specified using --proto_path (or -I).  You must specify a --proto_path which encompasses this file.  Note that the proto_path must be an exact prefix of the .proto file names -- protoc is too dumb to figure out when two paths (e.g. absolute and relative) are equivalent (it's harder than you think).
```
:::

Elixir側のコードを書きます。

```sh
# 空のファイルを作成
$ touch lib/exgrpc/client.ex
```

```elixir:grpchello/exgrpc/lib/exgrpc/client.ex
defmodule Exgrpc.Client do
  @moduledoc """
  クライアント側・Elixir版
  """

  @doc """
  クライアント

  ## Parameters

    - message: 送信する文字列
    - value: 送信する数値

  ## Examples
  """
  def request(message \\ "grpc-elixir", value \\ 1) do
    # 指定のホストにリクエストを送信
    ipport = "localhost:5001"
    {:ok, channel} = GRPC.Stub.connect(ipport)
    request = Hello.HelloRequest.new(message: message, value: value)
    {:ok, reply} = channel |> Hello.HelloService.Stub.say_hello(request)
    IO.puts("> response from server")
    IO.puts("---")
    IO.inspect(reply)
    IO.puts("---")
    {:ok, _} = GRPC.Stub.disconnect(channel)
    :ok
  end
end

```

ビルド、通信テストします。（相手側がいないので、数秒後に通信エラーにはなります）

```sh
# ビルド
$ iex -S mix

iex(1)> Exgrpc.request
** (MatchError) no match of right hand side value: {:error, :timeout}
    (exgrpc 0.1.0) lib/exgrpc.ex:24: Exgrpc.request/1
    iex:1: (file)
```

## （２）サーバー・Python側

poetryでPython環境を作ります。

```sh:ターミナル
$ poetry new pygrpc
$ cd pygrpc/

# 必要なライブラリをインストール
$ poetry add grpcio 
$ poetry add grpcio-tools

# 空のファイルを作成
$ touch pygrpc/server.py

# 実行権限を付与（poetry runするときに実行権限が必要）
$ chmod 755 pygrpc/server.py
```

protoファイルを解析してPythonのコードを生成します。

```sh:ターミナル
# ひとつ上のディレクトリのprotoファイルから、Python用のgRPCコードを生成
$ poetry run python -m grpc_tools.protoc \
    -I../ \
    --python_out=./ \
    --pyi_out=./ \
    --grpc_python_out=./ \
    ../hello.proto
```

Python側のコードを書きます。

```python:grpchello/pygrpc/pygrpc/server.py
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""サーバー側・Python版
"""

import pprint

from concurrent import futures
import grpc
import hello_pb2
import hello_pb2_grpc


class Greeter(hello_pb2_grpc.HelloServiceServicer):
    """レスポンスの処理"""

    def SayHello(self, request, context):
        """クライアントからのリクエストに対するレスポンスの処理"""

        # リクエストされた内容を表示
        print("> request from client")
        print("---")
        pprint.pprint(request)
        print("---")

        # 返り値としてリクエストの内容を文字列に組み立てる。クライアントにはこの文字列が表示される
        retval1 = f"hello: {request.message} / {request.value}"
        retval2 = request.value * 10
        return hello_pb2.HelloResponse(message=retval1, value=retval2)


def server():
    """サーバー"""
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
    hello_pb2_grpc.add_HelloServiceServicer_to_server(Greeter(), server)

    # 受付ポートを指定してサーバー起動
    ipport = "[::]:5001"
    server.add_insecure_port(ipport)
    server.start()
    print("running...")
    server.wait_for_termination()


if __name__ == "__main__":
    try:
        print("-- gRPC Server [Python] --")
        # サーバー起動
        server()
    except KeyboardInterrupt:
        # [Ctrl-C]が押されたとき
        print("SIGINT - Exit")
    except:
        # 例外発生時にメッセージ
        import traceback

        traceback.print_exc()
    finally:
        print("done.")
```

## （３）試行

ターミナルを２つ開いて、それぞれ実行します。

```sh:ターミナル１・受信用
$ poetry run pygrpc/server.py 
-- gRPC Server [Python] --
running...

# ↓ここからがクライアントのリクエストに対する応答
> request from client
---
message: "grpc-elixir"
value: 1

---
> request from client
---
message: "fuga"
value: 2

---
```

```sh:ターミナル２・送信用
$ cd grpchello/exgrpc
$ iex -S mix
```

```elixir:ターミナル２・送信用・iex
Erlang/OTP 25 [erts-13.2.2.3] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [jit:ns]

Interactive Elixir (1.15.5) - press Ctrl+C to exit (type h() ENTER for help)

# ↓１つ目のリクエストを送信
iex(1)> Exgrpc.Client.request           
> response from server
---
%Hello.HelloResponse{
  message: "hello: grpc-elixir / 1",
  value: 10,
  __unknown_fields__: []
}
---
:ok

# ↓２つ目のリクエストを送信
iex(2)> Exgrpc.Client.request("fuga", 2)
> response from server
---
%Hello.HelloResponse{
  message: "hello: fuga / 2",
  value: 20,
  __unknown_fields__: []
}
---
:ok
```

このように、簡単に異言語間のGRPCを作る事ができました。


:::note warn
`poetory`コマンド実行時のエラー例

**pyファイルに実行権限がない、あるいは、コードの先頭に`#!python3`が書かれていない**

```text
  PermissionError

  [Errno 13] Permission denied: '/home/username/gitwork/python/grpchello/pygrpc/pygrpc/server.py'
```

**カレントディレクトリの間違い・プロジェクトのディレクトリ以外でrunした**

```text
  RuntimeError

  Poetry could not find a pyproject.toml file in /home/username/gitwork/python/grpchello or its parents
```
:::


# ５．Python→Elixir間

つづいて本章では、Python側：クライアント、Elixir側：サーバーの挙動を試行します。

|役割|Elixir|Python|TCP/IPポート|
|:--|:--|:--|:--|
|クライアント|  |〇 |5002|
|サーバー   |〇|  |（同上）|

## （１）サーバー・Elixir側

```sh
# 空のファイルを作成
$ touch lib/exgrpc/server.ex
```

```elixir:grpchello/exgrpc/lib/exgrpc/server.ex
defmodule Exgrpc.Server do
  @moduledoc """
  サーバー側・Elixir版
  """
  use GRPC.Server, service: Hello.HelloService.Service

  @doc """
  クライアントからのリクエストに対するレスポンスの処理
  """
  @spec say_hello(Hello.HelloRequest.t(), GRPC.Server.Stream.t()) ::
          Hello.HelloReply.t()
  def say_hello(request, _stream) do
    # リクエストされた内容を表示
    IO.puts("> request from client")
    IO.puts("---")
    IO.inspect(request)
    IO.puts("---")

    # 返り値としてリクエストの内容を文字列に組み立てる。クライアントにはこの文字列が表示される
    retval1 = "hello: #{request.message} / #{request.value * 10}"
    retval2 = request.value * 10
    Hello.HelloResponse.new(message: retval1, value: retval2)
  end
end
```

さらに、既存のファイルのコードも修正します。

```elixir:grpchello/exgrpc/lib/exgrpc/application.ex
defmodule Exgrpc.Application do
  # See https://hexdocs.pm/elixir/Application.html
  # for more information on OTP Applications
  @moduledoc false

  use Application

  @impl true
  def start(_type, _args) do
    children = [
      # Starts a worker by calling: Exgrpc.Worker.start_link(arg)
      # {Exgrpc.Worker, arg}

      # ↓ここを追記
      # ↓コメントを外したら、GRPC.Server.Supervisorを自動起動。ポートは5002とする
      # {GRPC.Server.Supervisor, endpoint: Exgrpc.Endpoint, port: 5002, start_server: true}
    ]

    # See https://hexdocs.pm/elixir/Supervisor.html
    # for other strategies and supported options
    opts = [strategy: :one_for_one, name: Exgrpc.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

# ↓このモジュールを追記
defmodule Exgrpc.Endpoint do
  @moduledoc """
  エンドポイントを定義
  """
  use GRPC.Endpoint

  intercept(GRPC.Server.Interceptors.Logger)
  run(Exgrpc.Server)
end
```

:::note info
上記ソースコードの、`↓ここを追記`の部分
コメントを外してビルドすると、後述の`$ iex -S mix`でiexシェルを立ち上げた時に、自動的にサーバーを起動します。

このとき、後述の試行で、手順通りに`GRPC.Server.Supervisor.start_link`を実行すると、下記のエラーが表示されます。

```elixir
iex(1)> {:ok, pid} = GRPC.Server.Supervisor.start_link([endpoint: Exgrpc.Endpoint, port: 5002, start_server: true])  

23:31:21.244 [error] Failed to start Ranch listener "Exgrpc.Endpoint" in :ranch_tcp:listen([cacerts: :..., key: :..., cert: :..., port: 5002]) for reason :eaddrinuse (address already in use)

** (EXIT from #PID<0.260.0>) shell process exited with reason: shutdown: failed to start child: {:ranch_listener_sup, "Exgrpc.Endpoint"}
    ** (EXIT) shutdown: failed to start child: :ranch_acceptors_sup
        ** (EXIT) {:listen_error, "Exgrpc.Endpoint", :eaddrinuse}

Interactive Elixir (1.15.5) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> 
```
:::

## （２）クライアント・Python側

```sh
# 空のファイルを作成
$ touch pygrpc/pygrpc/client.py
```

```python:grpchello/pygrpc/pygrpc/client.py
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""クライアント側・Python版
ref:
  - https://grpc.io/docs/languages/python/quickstart/
"""

import time
import pprint

import grpc
import hello_pb2
import hello_pb2_grpc


def run(message: str = "hoge", value: int = 1):
    """クライアント

    Args:
        message (str, optional): 送信する文字列. Defaults to "hoge".
        value (int, optional): 送信する数値. Defaults to 1.
    """
    try:
        # 指定のホストにリクエストを送信
        ipport = "localhost:5002"
        with grpc.insecure_channel(ipport) as channel:
            stub = hello_pb2_grpc.HelloServiceStub(channel)
            response = stub.SayHello(
                hello_pb2.HelloRequest(message=message, value=value)
            )
        print("> response from server")
        print("---")
        pprint.pprint(response)
        print("---")
    except grpc.RpcError as rpc_error:
        # 例外発生の際の処理
        if rpc_error.code() == grpc.StatusCode.CANCELLED:
            print("CANCELLED")
        elif rpc_error.code() == grpc.StatusCode.UNAVAILABLE:
            print("UNAVAILABLE")
        else:
            print(
                f"Received unknown RPC error: code={rpc_error.code()} message={rpc_error.details()}"
            )


if __name__ == "__main__":
    try:
        print("-- gRPC Client [Python] --")
        # サーバーに2件送信
        run()
        time.sleep(1)
        run("fuga", 2)
    except KeyboardInterrupt:
        # [Ctrl-C]が押されたとき
        print("SIGINT - Exit")
    except:
        # 例外発生時にメッセージ
        import traceback

        traceback.print_exc()
    finally:
        print("done.")
```


:::note info
`grpc.RpcError`の例外の例
- https://stackoverflow.com/questions/64569062/how-to-handle-custom-exceptions-with-grpc-in-python
- https://qiita.com/Hiraku/items/0549e4cf7079d22b27e8
:::


## （３）試行

ターミナルを２つ開いて、それぞれ実行します。

```sh:ターミナル１・受信用
$ cd grpchello/exgrpc
$ iex -S mix
```

```elixir:ターミナル１・受信用・iex
# GRPC.Server.Supervisorを起動。ポートは5002とする
iex(1)> {:ok, pid} = GRPC.Server.Supervisor.start_link([endpoint: Exgrpc.Endpoint, port: 5002, start_server: true])  

22:46:31.418 [info] Running Exgrpc.Endpoint with Cowboy using http://0.0.0.0:5002
{:ok, #PID<0.243.0>}

# ↓ここからがクライアントのリクエストに対する応答
> request from client
---     
iex(2)> 
22:46:38.780 [info] Handled by Exgrpc.Server.say_hello
%Hello.HelloRequest{message: "hoge", value: 1, __unknown_fields__: []}
---     
iex(2)> 
22:46:38.783 [info] Response :ok in 3ms
> request from client
iex(2)> 
22:46:39.788 [info] Handled by Exgrpc.Server.say_hello
---
%Hello.HelloRequest{message: "fuga", value: 2, __unknown_fields__: []}
---     
iex(2)> 
22:46:39.789 [info] Response :ok in 113µs

```

```sh:ターミナル２・送信用
$ cd grpchello/pygrpc

# ↓２つのリクエストを送信
$ poetry run pygrpc/client.py 
-- gRPC Client [Python] --
> response from server
---
message: "hello: hoge / 10"
value: 10

---
> response from server
---
message: "hello: fuga / 20"
value: 20

---
done.
```

# ６．まとめ

過去にも試したことがあったのですが、いろいろはまってしまって、汎用的な内容にまとまらなかったので、「未解決」記事になっていました。

https://hotch-potch.hatenadiary.jp/entry/2020/05/11/200000

といったところで、ようやくElixir～Python間でのgRPC通信ができました。


# ７．参考資料

- Elixir
  - <https://github.com/elixir-grpc/grpc>
    - <https://github.com/elixir-protobuf/protobuf#usage>
  - <https://qiita.com/twinbee/items/644dcd6f3ed5924259cd>

- Python
  - <https://zenn.dev/sugasuga/articles/10e9843263f215>
  - <https://qiita.com/ny7760/items/1c793e7cd651c4c9e449>
  - <https://grpc.io/docs/languages/python/quickstart/>