10
3

ElixirとgRPCで異言語間通信

Last updated at Posted at 2023-12-27

1.はじめに

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

2.準備

ターミナル
$ cd (各自の作業ディレクトリに移動)
$ mkdir grpchello
$ cd !$

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

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

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

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

3.プロトコルバッファ

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

順番 変数名
1 string message
2 int32 value

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

ターミナル
$ touch helloworld.proto
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;    
}

4.Elixir→Python間

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

役割 Elixir Python TCP/IPポート
クライアント 5001
サーバー (同上)

(1)クライアント・Elixir側

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

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

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

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
・・・(省略)・・・

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

ターミナル
$ mix deps.get

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

ターミナル
$ 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のコードを生成します。

ターミナル
# 先ほどのツールが使えるようにパスを通す
$ 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

protocコマンド実行時のエラー例

パスの通し忘れ

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の指定忘れ

../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側のコードを書きます。

# 空のファイルを作成
$ touch lib/exgrpc/client.ex
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

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

# ビルド
$ 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)

(2)サーバー・Python側

poetryでPython環境を作ります。

ターミナル
$ 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のコードを生成します。

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

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.")

(3)試行

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

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

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

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

---
ターミナル2・送信用
$ cd grpchello/exgrpc
$ iex -S mix
ターミナル2・送信用・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)

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

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

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

poetoryコマンド実行時のエラー例

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

  PermissionError

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

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

  RuntimeError

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

5.Python→Elixir間

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

役割 Elixir Python TCP/IPポート
クライアント 5002
サーバー (同上)

(1)サーバー・Elixir側

# 空のファイルを作成
$ touch lib/exgrpc/server.ex
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

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

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

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

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

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)> 

(2)クライアント・Python側

# 空のファイルを作成
$ touch pygrpc/pygrpc/client.py
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.")

(3)試行

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

ターミナル1・受信用
$ cd grpchello/exgrpc
$ iex -S mix
ターミナル1・受信用・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

ターミナル2・送信用
$ cd grpchello/pygrpc

# ↓2つのリクエストを送信
$ 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.

6.まとめ

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

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

7.参考資料

10
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
3