Clojurianのlagénorhynque (a.k.a. カマイルカ🐬)です。
最近の私は仕事ではちょうどClojureのLaciniaでGraphQL API開発してみたの記事の例のような技術スタック(Lacinia + Pedestal + Duct)でGraphQL APIを開発しています。
マイクロサービスアーキテクチャのバックエンドサービス間の連携方式といえばgRPCが有力ですが、個人的にまだClojureに限らず本格的に試したことがありませんでした。
そこで今回は、Clojure向けにProtocol BuffersとgRPCをサポートするライブラリProtojureを利用してgRPCベースのAPIを試作してみることにします(最終的にgRPC公式チュートリアル(Python版)のコードを移植します)。
ちなみに、Protojureにはサーバ側だけでなくクライアント側の機能もあり(クライアント用のClojureコードも生成できる)、もちろん任意の言語でクライアント側を実装することができますが、本記事ではサーバ(API)機能のみを扱い、動作確認にはGUIクライアントInsomniaを利用することにしました。
最終的なサンプルAPIはこちら: lagenorhynque/route-guide
事前準備
まずはClojureの基本的な開発環境(ビルドツールLeiningen)に加えてProtocol BuffersとgRPCを扱うために必要なツールを用意します。
1. protoc
Protocol Buffers (protobuf)の .proto
ファイルをコンパイルするための protoc
(protocol compiler)をインストールします。
macOSの場合はHomebrewからインストールするのが簡単です。
$ brew install protobuf
# 動作確認
$ protoc --version
libprotoc 25.2
2. protocのClojureプラグイン
protoc
コマンドに対して各種言語用のプラグインが存在しますが、Clojureの場合にはProtojureのprotojure/protoc-pluginが利用可能です。
Installationの手順に従って最新版をインストールしましょう。
$ sudo curl -L https://github.com/protojure/protoc-plugin/releases/latest/download/protoc-gen-clojure --output /usr/local/bin/protoc-gen-clojure
$ sudo chmod +x /usr/local/bin/protoc-gen-clojure
# 動作確認
$ protoc-gen-clojure --version
protoc-gen-clojure version: v2.1.2
ProtojureのLeiningenテンプレートを試してみる
開発の準備ができたら、まずはProtojureが提供しているLeiningenテンプレートprotojure/lein-templateで全体の動作とコードのイメージを確認してみます。
- サンプルコードリポジトリ: lagenorhynque/hello-grpc
1. Protojureプロジェクトの生成
$ lein new protojure hello-grpc
Generating fresh 'lein new' protojure project.
$ tree hello-grpc/
hello-grpc/
├── Makefile
├── README.md
├── project.clj
├── resources
│ └── addressbook.proto
└── src
└── hello_grpc
├── server.clj
└── service.clj
3 directories, 6 files
初期状態ではこのような構成でプロジェクトが生成されます。
2. Clojureコードの生成
Quick Startの手順を参考に make
(= make all
)を実行してみると、
$ cd hello-grpc/
$ make
protoc --clojure_out=grpc-client,grpc-server:src --proto_path=resources resources/addressbook.proto
$ tree src/
src/
├── com
│ └── example
│ ├── addressbook
│ │ └── Greeter
│ │ ├── client.cljc
│ │ └── server.cljc
│ └── addressbook.cljc
└── hello_grpc
├── server.clj
└── service.clj
6 directories, 5 files
protoc --clojure_out=grpc-client,grpc-server:src --proto_path=resources resources/addressbook.proto
というコマンドによりresources/addressbook.protoの定義に基づいて3種類の .cljc
ファイルが生成されるようです。
- src/com/example/addressbook/Greeter/client.cljc: gRPCクライアント
- src/com/example/addressbook/Greeter/server.cljc: gRPCサーバ
- src/com/example/addressbook.cljc: Protocol Buffersに対応するClojureデータ(レコード)の定義とその相互変換のためのユーティリティ
3. 依存ライブラリの最新化
ここで、今回利用しているLeiningenテンプレートはProtojure関連ライブラリのバージョンが古くそのままでは動作しないことがあるようなので、lein-ancientやantqなどを活用して、依存ライブラリを適宜更新しておきましょう。
Clojure CLIでantqを outdated
というエイリアスで導入している場合、以下のコマンドが便利です。
$ clojure -M:outdated --upgrade
4. APIサーバの動作確認
この状態で lein run
でサーバを起動する、
$ lein run
Reflection warning, protojure/grpc/codec/lpm.clj:184:5 - reference to field close can't be resolved.
Creating your server...
14:45:57.132 [main] DEBUG org.jboss.logging -- Logging Provider: org.jboss.logging.Slf4jLoggerProvider
14:45:57.134 [main] INFO io.undertow -- starting server: Undertow - 2.3.8.Final
14:45:57.138 [main] INFO org.xnio -- XNIO version 3.8.8.Final
14:45:57.141 [main] INFO org.xnio.nio -- XNIO NIO Implementation Version 3.8.8.Final
14:45:57.148 [main] DEBUG org.xnio -- Creating worker:null, pool size:64, max pool size:64, keep alive:60000, io threads:8, stack size:0
14:45:57.153 [main] INFO org.jboss.threads -- JBoss Threads version 3.5.0.Final
14:45:57.156 [XNIO-1 I/O-1] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-1', selector sun.nio.ch.KQueueSelectorImpl@73b7819c
14:45:57.156 [XNIO-1 I/O-2] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-2', selector sun.nio.ch.KQueueSelectorImpl@150858c5
14:45:57.156 [XNIO-1 I/O-3] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-3', selector sun.nio.ch.KQueueSelectorImpl@568c22f5
14:45:57.156 [XNIO-1 I/O-4] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-4', selector sun.nio.ch.KQueueSelectorImpl@2efe0d32
14:45:57.156 [XNIO-1 I/O-5] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-5', selector sun.nio.ch.KQueueSelectorImpl@a6d1676
14:45:57.156 [XNIO-1 I/O-6] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-6', selector sun.nio.ch.KQueueSelectorImpl@5fa464f6
14:45:57.157 [XNIO-1 I/O-7] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-7', selector sun.nio.ch.KQueueSelectorImpl@2e002c01
14:45:57.157 [XNIO-1 I/O-8] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-8', selector sun.nio.ch.KQueueSelectorImpl@4376e4e7
14:45:57.157 [XNIO-1 Accept] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 Accept', selector sun.nio.ch.KQueueSelectorImpl@267b1df9
14:45:57.158 [main] DEBUG io.undertow -- Configuring listener with protocol HTTP for interface localhost and port 8080
もしくは lein uberjar
でfat jarに固めて java -jar
で実行すると、
$ lein uberjar
Compiling hello-grpc.server
Reflection warning, protojure/grpc/codec/lpm.clj:184:5 - reference to field close can't be resolved.
Created /Users/k.ohashi/repo/github.com/lagenorhynque/hello-grpc/target/hello-grpc-0.0.1-SNAPSHOT.jar
Created /Users/k.ohashi/repo/github.com/lagenorhynque/hello-grpc/target/hello-grpc-0.0.1-SNAPSHOT-standalone.jar
$ java -jar target/hello-grpc-0.0.1-SNAPSHOT-standalone.jar
Creating your server...
14:47:39.649 [main] DEBUG org.jboss.logging -- Logging Provider: org.jboss.logging.Slf4jLoggerProvider
14:47:39.651 [main] INFO io.undertow -- starting server: Undertow - 2.3.8.Final
14:47:39.657 [main] INFO org.xnio -- XNIO version 3.8.8.Final
14:47:39.661 [main] INFO org.xnio.nio -- XNIO NIO Implementation Version 3.8.8.Final
14:47:39.668 [main] DEBUG org.xnio -- Creating worker:null, pool size:64, max pool size:64, keep alive:60000, io threads:8, stack size:0
14:47:39.674 [main] INFO org.jboss.threads -- JBoss Threads version 3.5.0.Final
14:47:39.678 [XNIO-1 I/O-1] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-1', selector sun.nio.ch.KQueueSelectorImpl@6cd01003
14:47:39.678 [XNIO-1 I/O-5] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-5', selector sun.nio.ch.KQueueSelectorImpl@1c1d961a
14:47:39.678 [XNIO-1 I/O-2] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-2', selector sun.nio.ch.KQueueSelectorImpl@5986dab2
14:47:39.678 [XNIO-1 I/O-4] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-4', selector sun.nio.ch.KQueueSelectorImpl@2c8c1438
14:47:39.678 [XNIO-1 I/O-3] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-3', selector sun.nio.ch.KQueueSelectorImpl@6a861d8
14:47:39.678 [XNIO-1 I/O-6] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-6', selector sun.nio.ch.KQueueSelectorImpl@402f382a
14:47:39.678 [XNIO-1 I/O-7] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-7', selector sun.nio.ch.KQueueSelectorImpl@7fbee37a
14:47:39.678 [XNIO-1 I/O-8] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 I/O-8', selector sun.nio.ch.KQueueSelectorImpl@436460ad
14:47:39.678 [XNIO-1 Accept] DEBUG org.xnio.nio -- Started channel thread 'XNIO-1 Accept', selector sun.nio.ch.KQueueSelectorImpl@4a32c322
14:47:39.680 [main] DEBUG io.undertow -- Configuring listener with protocol HTTP for interface localhost and port 8080
GUIクライアントInsomniaで resources/addressbook.proto
を指定して localhost:8080
に対してgRPCリクエストを実行すると、例えば以下のようにアクセスすることができました。
4. API実装の検討
それでは、ここで動作しているAPIサーバの実装を簡単に調べてみましょう。
service.clj
でサービスマップを定義し、 server.clj
でサーバを起動する、というのはPedestalのサンプルコードを見たことがあればお馴染みの構成です。
cf. Clojureサービス開発ライブラリPedestal入門
(ns hello-grpc.service
(:require [io.pedestal.http :as http]
[io.pedestal.http.route :as route]
[io.pedestal.http.body-params :as body-params]
[ring.util.response :as ring-resp]
;; -- PROTOC-GEN-CLOJURE --
[protojure.pedestal.core :as protojure.pedestal]
[protojure.pedestal.routes :as proutes]
[com.example.addressbook.Greeter.server :as greeter]
[com.example.addressbook :as addressbook]))
(defn about-page
[request]
(ring-resp/response (format "Clojure %s - served from %s"
(clojure-version)
(route/url-for ::about-page))))
(defn home-page
[request]
(ring-resp/response "Hello from hello-grpc, backed by Protojure Template!"))
;; -- PROTOC-GEN-CLOJURE --
;; Implement our "Greeter" service interface. The compiler generates
;; a defprotocol (greeter/Service, in this case), and it is our job
;; to define an implementation of every function within it. These will be
;; invoked whenever a request arrives, similarly to if we had defined
;; these functions as pedestal defhandlers. The main difference is that
;; the :body returned in the response should correlate to the protobuf
;; return-type declared in the Service definition within the .proto
;;
;; Note that our GRPC parameters are associated with the request-map
;; as :grpc-params, similar to how the pedestal body-param module
;; injects other types, like :json-params, :edn-params, etc.
;;
;; see http://pedestal.io/reference/request-map
(deftype Greeter []
greeter/Service
(Hello
[this {{:keys [name]} :grpc-params :as request}]
{:status 200
:body {:message (str "Hello, " name)}}))
;; Defines "/" and "/about" routes with their associated :get handlers.
;; The interceptors defined after the verb map (e.g., {:get home-page}
;; apply to / and its children (/about).
(def common-interceptors [(body-params/body-params) http/html-body])
;; Tabular routes
(def routes #{["/" :get (conj common-interceptors `home-page)]
["/about" :get (conj common-interceptors `about-page)]})
;; -- PROTOC-GEN-CLOJURE --
;; Add the routes produced by Greeter->routes
(def grpc-routes (reduce conj routes (proutes/->tablesyntax {:rpc-metadata greeter/rpc-metadata :interceptors common-interceptors :callback-context (Greeter.)})))
(def service {:env :prod
::http/routes grpc-routes
;; -- PROTOC-GEN-CLOJURE --
;; We override the chain-provider with one provided by protojure.protobuf
;; and based on the Undertow webserver. This provides the proper support
;; for HTTP/2 trailers, which GRPCs rely on. A future version of pedestal
;; may provide this support, in which case we can go back to using
;; chain-providers from pedestal.
::http/type protojure.pedestal/config
::http/chain-provider protojure.pedestal/provider
;;::http/host "localhost"
::http/port 8080})
注目すべきは deftype Greeter
によるgRPCサービスの実装と def grpc-routes
によるgRPC API用のルーティング定義でしょう。
greeter/Service
は .proto
ファイルから生成されたClojureのプロトコルで、gRPCのサービス定義に対応するメソッドを持っています。
deftype
でそのプロトコルを実装した型を定義しておき、そのインスタンス (Greeter.)
とメタデータ greeter/rpc-metadata
、共通のインターセプタ common-interceptors
から proutes/->tablesyntax
関数でPedestalのルーティングデータに変換しているようです。
(ns hello-grpc.server
(:gen-class) ; for -main method in uberjar
(:require [io.pedestal.http :as server]
[io.pedestal.http.route :as route]
[hello-grpc.service :as service]))
;; This is an adapted service map, that can be started and stopped
;; From the REPL you can call server/start and server/stop on this service
(defonce runnable-service (server/create-server service/service))
(defn run-dev
"The entry-point for 'lein run-dev'"
[& args]
(println "\nCreating your [DEV] server...")
(-> service/service ;; start with production configuration
(merge {:env :dev
;; do not block thread that starts web server
::server/join? false
;; Routes can be a function that resolve routes,
;; we can use this to set the routes to be reloadable
::server/routes #(route/expand-routes (deref #'service/grpc-routes)) ;; -- PROTOC-GEN-CLOJURE -- update route
;; all origins are allowed in dev mode
::server/allowed-origins {:creds true :allowed-origins (constantly true)}
;; Content Security Policy (CSP) is mostly turned off in dev mode
::server/secure-headers {:content-security-policy-settings {:object-src "none"}}})
;; Wire up interceptor chains
server/default-interceptors
server/dev-interceptors
server/create-server
server/start))
(defn -main
"The entry-point for 'lein run'"
[& args]
(println "\nCreating your server...")
(server/start runnable-service))
;; If you package the service up as a WAR,
;; some form of the following function sections is required (for io.pedestal.servlet.ClojureVarServlet).
;;(defonce servlet (atom nil))
;;
;;(defn servlet-init
;; [_ config]
;; ;; Initialize your app here.
;; (reset! servlet (server/servlet-init service/service nil)))
;;
;;(defn servlet-service
;; [_ request response]
;; (server/servlet-service @servlet request response))
;;
;;(defn servlet-destroy
;; [_]
;; (server/servlet-destroy @servlet)
;; (reset! servlet nil))
こちらには特別な実装はなく、Pedestalで開発/本番環境向けにAPIサーバを起動するための素直なコードのようです。
service.clj
で定義したサービスマップ service/service
とルーティングデータ service/grpc-routes
を利用してAPIサーバを起動するコードが書かれています。
Ductに載せてみる
ProtojureでgRPC APIを開発するための基本的な仕組みが把握できたので、アプリケーション構成を整えつつREPL駆動開発を快適にするためにいつも通りDuctに載せてみましょう。
cf. ClojureサーバサイドフレームワークDuctガイド
- サンプルコードリポジトリ: lagenorhynque/route-guide
1. Ductプロジェクトの生成
まずはDuctのLeiningenテンプレートでプロジェクトを生成します。
$ lein new duct route-guide
ここでは特にオプションは指定せず、ミニマルなDuctプロジェクトとしました。
2. 依存ライブラリの追加/更新
ProtojureのLeiningenテンプレートで生成されたproject.cljを参考に必要な依存ライブラリを追加し、既存のライブラリも適宜更新します。
(defproject route-guide "0.1.0"
:description "Route Guide, an example gRPC API"
:url "https://github.com/lagenorhynque/route-guide"
:min-lein-version "2.8.1"
:dependencies [[duct.module.cambium "1.3.1" :exclusions [cheshire
org.clojure/tools.logging]]
[duct.module.pedestal "2.2.0"]
[duct/core "0.8.1"]
[integrant "0.8.0"]
[io.github.protojure/google.protobuf "2.0.1" :exclusions [io.github.protojure/core]]
[io.github.protojure/grpc-server "2.8.1" :exclusions [io.pedestal/pedestal.log]]
[org.clojure/clojure "1.11.1"]
[org.slf4j/slf4j-api "2.0.11"]]
:plugins [[duct/lein-duct "0.12.3"]]
:main ^:skip-aot route-guide.main
:resource-paths ["resources" "target/resources"]
:prep-tasks ["javac" "compile" ["run" ":duct/compiler"]]
:middleware [lein-duct.plugin/middleware]
:profiles
{:repl {:prep-tasks ^:replace ["javac" "compile"]
:repl-options {:init-ns user}}
:dev [:shared :project/dev :profiles/dev]
:test [:shared :project/dev :project/test :profiles/test]
:uberjar [:shared :project/uberjar]
:shared {}
:project/dev {:source-paths ["dev/src"]
:resource-paths ["dev/resources"]
:dependencies [[clj-http "3.12.3" :exclusions [commons-io]]
[com.bhauman/rebel-readline "0.1.4"]
[eftest "0.6.0" :exclusions [org.clojure/tools.logging
org.clojure/tools.namespace]]
[fipp "0.6.26"]
[hawk "0.2.11"]
[integrant/repl "0.3.3" :exclusions [integrant]]
[orchestra "2021.01.01-1"]
[pjstadig/humane-test-output "0.11.0"]]
:plugins [[jonase/eastwood "1.4.2"]
[lein-ancient "0.7.0"]
[lein-cloverage "1.2.4"]
[lein-codox "0.10.8"]]
:aliases {"rebel" ^{:doc "Run REPL with rebel-readline."}
["trampoline" "run" "-m" "rebel-readline.main"]
"test-coverage" ^{:doc "Execute cloverage."}
["cloverage" "--ns-exclude-regex" "^(:?dev|user)$" "--codecov" "--junit"]
"lint" ^{:doc "Execute eastwood."}
["eastwood" "{:config-files [\"dev/resources/eastwood_config.clj\"]
:source-paths [\"src\"]
:test-paths []}"]}
:injections [(require 'pjstadig.humane-test-output)
(pjstadig.humane-test-output/activate!)]
:codox {:output-path "target/codox"
:source-uri "https://github.com/lagenorhynque/route-guide/blob/master/{filepath}#L{line}"
:metadata {:doc/format :markdown}}}
:project/test {}
:project/uberjar {:aot :all
:uberjar-name "route-guide.jar"}
:profiles/dev {}
:profiles/test {}})
ProtojureでgRPC APIを開発するには次の2種類のライブラリが必要です。
- io.github.protojure/grpc-server: Protojureのサーバ実装用ライブラリ
- io.github.protojure/google.protobuf: Protocol Buffersの型のClojure実装
また、以下のDuctモジュールも導入しています。
- duct.module.pedestal: Pedestalの組み込み
- duct.module.cambium: JSON形式でのロギングライブラリCambiumの組み込み
3. Clojureコードの生成
Protojureのテンプレートの場合と同様に、 .proto
ファイルからClojureコードを生成します。
ただし、今回はgRPCサーバ用のコードのみ生成したい(gRPCクライアント用のコードは使わない)ので、以下のようなコマンドをMakefileに追加して make gen-clj
で生成できるようにしてみました。
- Makefile (※ 抜粋)
.PHONY: gen-clj
gen-clj:
protoc --clojure_out=grpc-server:src --proto_path=resources resources/route_guide/*.proto
さらに、ClojurianとしてはREPLからも手軽に実行できたほうが楽なので、 dev
名前空間に gen-clj
という関数を定義して
- dev/src/dev.clj (※ 抜粋)
(defn gen-clj []
(let [{:keys [exit out err]} (shell/sh "make" "gen-clj")]
(print out)
(when-not (zero? exit)
(print err))))
(gen-clj)
でコード生成できるようにしました。
dev> (gen-clj)
protoc --clojure_out=grpc-server:src --proto_path=resources resources/route_guide/*.proto
nil
さらに進んでDuct (Integrant)システムのライフサイクルの中でコード生成/更新を行うこともできそうですが、ここでは単体で実行できるようにするのにとどめました。
システム起動/再起動のたびに毎回コード生成する必要はないので、 .proto
ファイルに更新があった時だけClojureコードを自動で再生成するような工夫ができると便利かもしれません(Gitのコミット時にフックすれば十分かも?)。
4. gRPCサービスの実装
Ductのコンポーネント設定を設定マップに書き、対応する初期化処理 integrant.core/init-key
の実装を与えることでAPIサーバとしてProtojureのテンプレートと同様に動作するようにします。
4.1. Ductの設定マップ
{:duct.profile/base
{:duct.core/project-ns route-guide
:duct.server/pedestal
{:base-service #ig/ref :route-guide.grpc/service
:service #:io.pedestal.http{:routes #ig/ref :route-guide.grpc/routes
:join? true
:host #duct/env "SERVER_HOST"
:port #duct/env ["SERVER_PORT" Int :or 8080]}}
:route-guide.grpc/routes {}
:route-guide.grpc/service
{:options {:env :prod}}}
:duct.profile/dev #duct/include "dev"
:duct.profile/test #duct/include "test"
:duct.profile/local #duct/include "local"
:duct.profile/prod {}
:duct.module/cambium {}
:duct.module/pedestal {}}
Ductの config.edn
の構成はコンポーネントの設計方針次第で変わりますが、今回はDuctモジュールduct.module.pedestalを利用しているので、Pedestalのサービスマップの静的な設定は :duct.server/pedestal
に与え、Protojureのルーティング定義とサービス設定をそれぞれ :route-guide.grpc/routes
, :route-guide.grpc/service
というアプリケーション独自のコンポーネントとして注入できるようにしてみました。
{:duct.server/pedestal
{:service #:io.pedestal.http{:join? false}}
:route-guide.grpc/service
{:options {:env :dev}}}
{:duct.server/pedestal
{:service #:io.pedestal.http{:port 8081}}}
dev.edn
, test.edn
にあるのは config.edn
からの差分の設定だけです。
4.2. gRPCサービス
ここが本題のgRPCのサービス実装です。
関心の分離の観点からPedestalのルーティングやサービスマップなどとは分けておきたいので、 Greeter
サービスのための独立した名前空間 route-guide.service.greeter
を用意しました。
(ns route-guide.service.greeter
(:require
[com.example.addressbook.Greeter.server :as greeter]
[ring.util.response :refer [response]]))
(def service
{:rpc-metadata greeter/rpc-metadata
:callback-context
(reify greeter/Service
(Hello [_ {{:keys [name]} :grpc-params}]
(response {:message (str "Hello, " name)})))})
Protojureのテンプレートではプロトコル greeter/Service
に対する実装型を deftype
で定義してインスタンス化したものを利用していましたが、繰り返し利用するものでもないので reify
で実装してメタデータとともに service
と名付けたマップに収めてみました。
4.3. コンポーネントの初期化処理
最後に、Pedestalのルーティング定義と設定マップの動的な部分のためのコンポーネント :route-guide.grpc/routes
, :route-guide.grpc/service
に対する初期化処理を実装します。
(ns route-guide.grpc
(:require
[integrant.core :as ig]
[io.pedestal.http :as http]
[io.pedestal.http.body-params :as body-params]
[protojure.pedestal.core :as protojure.pedestal]
[protojure.pedestal.routes :as proutes]
[route-guide.service.greeter :as greeter]))
(defmethod ig/init-key ::routes
[_ _]
(let [common-interceptors [(body-params/body-params)]]
(-> greeter/service
(assoc :interceptors common-interceptors)
proutes/->tablesyntax
set)))
(defmethod ig/init-key ::service
[_ {:keys [options]}]
(assoc options
::http/type protojure.pedestal/config
::http/chain-provider protojure.pedestal/provider))
ここまでで、Protojureテンプレートと機能的に同等のgRPCサーバが実装できたはずです。
5. APIサーバの動作確認
REPLからAPIサーバを起動して、動作を確かめてみましょう。
user> (dev)
:loaded
dev> (reset)
:reloading (com.example.addressbook com.example.addressbook.Greeter.server route-guide.service.greeter route-guide.grpc dev route-guide.main user)
Reflection warning, protojure/grpc/codec/lpm.clj:184:5 - reference to field close can't be resolved.
Creating your [DEV] server...
:resumed
Protojureのテンプレートの場合と同様にGUIクライアントInsomniaからアクセスすることができます。
もちろん lein run
での起動や lein uberjar
で生成したfat jarからの起動でも同様に動作します。
gRPC公式チュートリアル(Python版)を移植してみる
Ductプロジェクトとして快適な開発環境が得られたので、実例を通してgRPCの基本的な機能を一通り試すべく、gRPC公式ドキュメントにあるチュートリアル(Python版)のコードをProtojureによるClojure実装として移植してみましょう。
- チュートリアル: Basics tutorial | Python | gRPC
- Python版サンプルコード: https://github.com/grpc/grpc/blob/v1.34.0/examples/python/route_guide/route_guide_server.py
-
.proto
ファイル: https://github.com/grpc/grpc/blob/v1.34.0/examples/protos/route_guide.proto
Protojureでの実装方法はProtojure公式ドキュメントやテストコードを参考にしました。
- Protojureのドキュメント: Quick Start - Protojure
- Protojureのテストコード: https://github.com/protojure/lib/blob/master/test/protojure/grpc_test.clj
- Clojure版の最終形: lagenorhynque/route-guide
1. Clojureコードの生成
まずは .proto
ファイルをダウンロードしてresources/route_guide/route_guide.protoに配置し、先ほど用意した gen-clj
関数を利用してClojureコードを生成します。
dev> (gen-clj)
protoc --clojure_out=grpc-server:src --proto_path=resources resources/route_guide/*.proto
nil
2. gRPCサービスの実装
Python版のサンプルコードexamples/python/route_guide/route_guide_server.pyの実装をProtojureの仕組みを使ってClojureコードに書き換えていきます。
最終的には全体として以下のようなコードになりました。
(ns route-guide.service.route-guide
(:require
[clojure.core.async :as async]
[io.grpc.examples.routeguide.RouteGuide.server :as route-guide]
[ring.util.response :refer [response]]
[route-guide.boundary.db.feature :as db.feature])
(:import
(java.time
Duration
Instant)))
(defn calculate-dinstance [start end]
(let [coord-factor 10000000.0
lat-1 (/ (:latitude start) coord-factor)
lat-2 (/ (:latitude end) coord-factor)
lon-1 (/ (:longitude start) coord-factor)
lon-2 (/ (:longitude end) coord-factor)
lat-rad-1 (Math/toRadians lat-1)
lat-rad-2 (Math/toRadians lat-2)
delta-lat-rad (Math/toRadians (- lat-2 lat-1))
delta-lon-rad (Math/toRadians (- lon-2 lon-1))
;; Formula is based on http://mathforum.org/library/drmath/view/51879.html
a (+ (Math/pow (Math/sin (/ delta-lat-rad 2)) 2)
(* (Math/cos lat-rad-1)
(Math/cos lat-rad-2)
(Math/pow (Math/sin (/ delta-lon-rad 2)) 2)))
c (* 2 (Math/atan2 (Math/sqrt a) (Math/sqrt (- 1 a))))
r 6371000]
(* r c)))
(defn summarize-points [db point-ch]
(async/go-loop [point (async/<! point-ch)
prev-point nil
summary {:point-count 0
:feature-count 0
:distance 0.0}]
(if point
(recur (async/<! point-ch)
point
(cond-> summary
true (update :point-count inc)
(db.feature/find-feature-by-point db point) (update :feature-count inc)
prev-point (update :distance + (calculate-dinstance prev-point point))))
summary)))
(def service
{:rpc-metadata (map #(assoc % :pkg "routeguide")
route-guide/rpc-metadata)
:callback-context
(reify route-guide/Service
(GetFeature [_ {:keys [db]
point :grpc-params}]
(if-let [feature (db.feature/find-feature-by-point db point)]
(response feature)
(response {:name ""
:location point})))
(ListFeatures [_ {:keys [db]
rectangle :grpc-params
out-ch :grpc-out}]
(db.feature/find-features-within-rectangle db out-ch rectangle)
(response out-ch))
(RecordRoute [_ {:keys [db]
point-ch :grpc-params}]
(let [start-time (Instant/now)
summary (async/<!! (summarize-points db point-ch))
elapsed-time (Duration/between start-time (Instant/now))]
(response (-> summary
(update :distance long)
(assoc :elapsed-time (.getSeconds elapsed-time))))))
(RouteChat [_ {route-note-ch :grpc-params
out-ch :grpc-out}]
(async/go-loop [new-note (async/<! route-note-ch)
prev-notes []]
(if new-note
(do (doseq [prev-note prev-notes
:when (= (:location prev-note)
(:location new-note))]
(async/>! out-ch prev-note))
(recur (async/<! route-note-ch)
(conj prev-notes new-note)))
(async/close! out-ch)))
(response out-ch)))})
route-guide/rpc-metadata
に対して map
関数で各要素の :pkg
の値を "routeguide"
に置き換えています。
これは、今回扱っているチュートリアルの .proto
ファイルの定義では package routeguide;
と option java_package = "io.grpc.examples.routeguide";
の設定があり、Protojureのデフォルト挙動では後者に基づいて自動生成Clojureコードの名前空間が定義されるだけではなくAPIエンドポイントのパスも決まってしまうためで、ここでは前者のパッケージ名に基づいたパスにしたいのでこのような変換を入れています(例えば GetFeature
メソッドに対して /io.grpc.examples.routeguide.RouteGuide/GetFeature
ではなく /routeguide.RouteGuide/GetFeature
というエンドポイントになるようにしたい)。
サービスのプロトコル route-guide/Service
の実装の詳細についてはこのあと個別に説明します(4種類のメソッドがちょうどgRPCの4種類のメソッドパターンに対応しています)。
また、本記事では詳細に立ち入りませんが、DB (今回の場合はデータソースがJSONファイル)のような外部リソースに対するアクセスは疎結合に実装したいので、Ductらしく"boundary"のプロトコルを定義し、実装した型のデータをPedestalのインターセプタ(interceptor)を介して注入するようにしてみました(詳細はリポジトリのコード参照)。
(ns route-guide.boundary.db.core
(:require
[cheshire.core :as cheshire]
[clojure.java.io :as io]
[integrant.core :as ig]))
(defrecord Boundary [data])
(defmethod ig/init-key ::db
[_ {:keys [path]}]
(-> (io/resource path)
io/reader
(cheshire.core/parse-stream-strict true)
->Boundary))
(ns route-guide.boundary.db.feature
(:require
[clojure.core.async :as async]
[route-guide.boundary.db.core]))
(defprotocol Feature
(find-feature-by-point [db point])
(find-features-within-rectangle [db out-ch rectangle]))
(extend-protocol Feature
route_guide.boundary.db.core.Boundary
(find-feature-by-point [{:keys [data]} {:keys [latitude longitude]}]
(some (fn [{:keys [location]
:as feature}]
(when (and (= (:latitude location) latitude)
(= (:longitude location) longitude))
feature))
data))
(find-features-within-rectangle [{:keys [data]} out-ch {:keys [lo hi]}]
(let [left (min (:longitude lo) (:longitude hi))
right (max (:longitude lo) (:longitude hi))
top (max (:latitude lo) (:latitude hi))
bottom (min (:latitude lo) (:latitude hi))]
(async/go
(doseq [{:keys [location]
:as feature} data
:when (and (<= left (:longitude location) right)
(<= bottom (:latitude location) top))]
(async/>! out-ch feature))
(async/close! out-ch)))))
ここからは、サービス定義の4種類のメソッドに対する実装をそれぞれ見てみましょう。
2.1. GetFeature
メソッドの実装: simple RPC
まずは単純にリクエストを受け取ってレスポンスを返すパターンです。
// A simple RPC.
//
// Obtains the feature at a given position.
//
// A feature with an empty name is returned if there's no feature at the given
// position.
rpc GetFeature(Point) returns (Feature) {}
Python版では、メソッドの(レシーバを含めて)第2引数で入力データ Point
を受け取り、戻り値として Feature
を返しているようです。
def GetFeature(self, request, context):
feature = get_feature(self.db, request)
if feature is None:
return route_guide_pb2.Feature(name="", location=request)
else:
return feature
Protojureでは、第2引数(リクエストマップ)の :grpc-params
の値として入力データ Point
を受け取り、 Feature
マップをボディとするレスポンスマップを返すことで同等の振る舞いになります。
Clojureのプロトコルのメソッドは第1引数が this
になる(ちょうどPythonのメソッドと同じ形式)ので引数が1個ズレていますが、Protojureにおけるサービス(プロトコル)のメソッドはリクエストマップ(request map)を受け取ってレスポンスマップ(response map)を返すRingのハンドラ関数(handler function)と同じようなものであることが分かります。
Ringのハンドラ関数とまったく同じように、Pedestalのインターセプタであらかじめリクエストマップに追加のデータ(ここではDBデータ :db
)を詰めておくと、メソッドの第2引数のマップから取り出すことができました。
(GetFeature [_ {:keys [db]
point :grpc-params}]
(if-let [feature (db.feature/find-feature-by-point db point)]
(response feature)
(response {:name ""
:location point})))
2.2. ListFeatures
メソッドの実装: response-streaming RPC
次はレスポンスをストリーミングで返すパターンです。
// A server-to-client streaming RPC.
//
// Obtains the Features available within the given Rectangle. Results are
// streamed rather than returned at once (e.g. in a response message with a
// repeated field), as the rectangle may cover a large area and contain a
// huge number of features.
rpc ListFeatures(Rectangle) returns (stream Feature) {}
Python版では、ジェネレータ(for文内の yield feature
)によってレスポンスとして Feature
を繰り返し送ることができるようです。
def ListFeatures(self, request, context):
left = min(request.lo.longitude, request.hi.longitude)
right = max(request.lo.longitude, request.hi.longitude)
top = max(request.lo.latitude, request.hi.latitude)
bottom = min(request.lo.latitude, request.hi.latitude)
for feature in self.db:
if (feature.location.longitude >= left and
feature.location.longitude <= right and
feature.location.latitude >= bottom and
feature.location.latitude <= top):
yield feature
Protojureでは、第2引数(リクエストマップ)の :grpc-out
の値としてcore.asyncのチャネル(channel)を受け取れるので、返したい Feature
の値を非同期的に流し込む(find-features-within-rectangle
関数の (async/>! out-ch feature)
)ようにしておき、レスポンスマップのボディとしてそのチャネルを返せば同等のことができます。
(ListFeatures [_ {:keys [db]
rectangle :grpc-params
out-ch :grpc-out}]
(db.feature/find-features-within-rectangle db out-ch rectangle)
(response out-ch))
(find-features-within-rectangle [{:keys [data]} out-ch {:keys [lo hi]}]
(let [left (min (:longitude lo) (:longitude hi))
right (max (:longitude lo) (:longitude hi))
top (max (:latitude lo) (:latitude hi))
bottom (min (:latitude lo) (:latitude hi))]
(async/go
(doseq [{:keys [location]
:as feature} data
:when (and (<= left (:longitude location) right)
(<= bottom (:latitude location) top))]
(async/>! out-ch feature))
(async/close! out-ch))))
2.3. RecordRoute
メソッドの実装: request-streaming RPC
今度は反対にリクエストをストリーミングで受け取るパターンです。
// A client-to-server streaming RPC.
//
// Accepts a stream of Points on a route being traversed, returning a
// RouteSummary when traversal is completed.
rpc RecordRoute(stream Point) returns (RouteSummary) {}
Python版では、第2引数で入力データとしてイテレータを受け取ってそこから Point
の値を順に取り出すことができるようです。
def RecordRoute(self, request_iterator, context):
point_count = 0
feature_count = 0
distance = 0.0
prev_point = None
start_time = time.time()
for point in request_iterator:
point_count += 1
if get_feature(self.db, point):
feature_count += 1
if prev_point:
distance += get_distance(prev_point, point)
prev_point = point
elapsed_time = time.time() - start_time
return route_guide_pb2.RouteSummary(point_count=point_count,
feature_count=feature_count,
distance=int(distance),
elapsed_time=int(elapsed_time))
Protojureでは、第2引数(リクエストマップ)の :grpc-params
の値としてcore.asyncのチャネルを受け取り、そこから Point
の値を順に取り出す(summarize-points
関数の (async/<! point-ch)
)ことができます。
(defn summarize-points [db point-ch]
(async/go-loop [point (async/<! point-ch)
prev-point nil
summary {:point-count 0
:feature-count 0
:distance 0.0}]
(if point
(recur (async/<! point-ch)
point
(cond-> summary
true (update :point-count inc)
(db.feature/find-feature-by-point db point) (update :feature-count inc)
prev-point (update :distance + (calculate-dinstance prev-point point))))
summary)))
;; 途中省略
(RecordRoute [_ {:keys [db]
point-ch :grpc-params}]
(let [start-time (Instant/now)
summary (async/<!! (summarize-points db point-ch))
elapsed-time (Duration/between start-time (Instant/now))]
(response (-> summary
(update :distance long)
(assoc :elapsed-time (.getSeconds elapsed-time))))))
2.4. RouteChat
メソッドの実装: bidirectional streaming RPC
最後はリクエストもレスポンスもストリーミングで扱うパターンです。
// A Bidirectional streaming RPC.
//
// Accepts a stream of RouteNotes sent while a route is being traversed,
// while receiving other RouteNotes (e.g. from other users).
rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}
これはリクエストをストリーミングで受け取るパターンとレスポンスをストリーミングで返すパターンの複合形だといえます。
Python版でもProtojureでも、両パターンの実装方法の単純な組み合わせで実現できます(Python版ではイテレータから取り出してジェネレータで返し、Protojureではcore.asyncのチャネルから取り出して別のチャネルに流し込む)。
def RouteChat(self, request_iterator, context):
prev_notes = []
for new_note in request_iterator:
for prev_note in prev_notes:
if prev_note.location == new_note.location:
yield prev_note
prev_notes.append(new_note)
(RouteChat [_ {route-note-ch :grpc-params
out-ch :grpc-out}]
(async/go-loop [new-note (async/<! route-note-ch)
prev-notes []]
(if new-note
(do (doseq [prev-note prev-notes
:when (= (:location prev-note)
(:location new-note))]
(async/>! out-ch prev-note))
(recur (async/<! route-note-ch)
(conj prev-notes new-note)))
(async/close! out-ch)))
(response out-ch))
まとめ
- Protojureを利用すると、Clojureとしてとても自然な形でgRPC APIを実装することができた
- Duct, Pedestal, Protojureという組み合わせでクリーンなアプリケーション構成と快適な開発環境を得ることができた
- ProtojureでgRPCのストリーミングはcore.asyncのチャネルを介して実現できると分かった
- ClojureでgRPCのAPIを開発するならProtojureはかなり良い選択肢になりそう(実用に向けてさらに詳細を探ってみたい)
- やはりClojureは超楽しい>ω</