LoginSignup
4

ClojureのProtojureでgRPC API開発してみた

Last updated at Posted at 2021-01-04

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で全体の動作とコードのイメージを確認してみます。

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 ファイルが生成されるようです。

3. 依存ライブラリの最新化

ここで、今回利用しているLeiningenテンプレートはProtojure関連ライブラリのバージョンが古くそのままでは動作しないことがあるようなので、lein-ancientantqなどを活用して、依存ライブラリを適宜更新しておきましょう。

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リクエストを実行すると、例えば以下のようにアクセスすることができました。

Insomniaでの動作確認例

4. API実装の検討

それでは、ここで動作しているAPIサーバの実装を簡単に調べてみましょう。

service.clj でサービスマップを定義し、 server.clj でサーバを起動する、というのはPedestalのサンプルコードを見たことがあればお馴染みの構成です。

cf. Clojureサービス開発ライブラリPedestal入門

src/hello_grpc/service.clj
(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のルーティングデータに変換しているようです。

src/hello_grpc/server.clj
(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ガイド

1. Ductプロジェクトの生成

まずはDuctのLeiningenテンプレートでプロジェクトを生成します。

$ lein new duct route-guide

ここでは特にオプションは指定せず、ミニマルなDuctプロジェクトとしました。

2. 依存ライブラリの追加/更新

ProtojureのLeiningenテンプレートで生成されたproject.cljを参考に必要な依存ライブラリを追加し、既存のライブラリも適宜更新します。

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種類のライブラリが必要です。

また、以下のDuctモジュールも導入しています。

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の設定マップ

resources/route_guide/config.edn
{: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 というアプリケーション独自のコンポーネントとして注入できるようにしてみました。

dev/resources/dev.edn
{:duct.server/pedestal
 {:service #:io.pedestal.http{:join? false}}

 :route-guide.grpc/service
 {:options {:env :dev}}}
dev/resources/test.edn
{: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 を用意しました。

src/route_guide/service/greeter.clj
(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 に対する初期化処理を実装します。

src/route_guide/grpc.clj
(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からアクセスすることができます。

Insomniaでの動作確認例

もちろん lein run での起動や lein uberjar で生成したfat jarからの起動でも同様に動作します。

gRPC公式チュートリアル(Python版)を移植してみる

Ductプロジェクトとして快適な開発環境が得られたので、実例を通してgRPCの基本的な機能を一通り試すべく、gRPC公式ドキュメントにあるチュートリアル(Python版)のコードをProtojureによるClojure実装として移植してみましょう。

Protojureでの実装方法はProtojure公式ドキュメントやテストコードを参考にしました。

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コードに書き換えていきます。

最終的には全体として以下のようなコードになりました。

src/route_guide/service/route_guide.clj
(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)を介して注入するようにしてみました(詳細はリポジトリのコード参照)。

src/route_guide/boundary/db/core.clj
(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))
src/route_guide/boundary/db/feature.clj
(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})))

image.png

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

image.png

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

image.png

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

image.png

まとめ

  • Protojureを利用すると、Clojureとしてとても自然な形でgRPC APIを実装することができた
  • Duct, Pedestal, Protojureという組み合わせでクリーンなアプリケーション構成と快適な開発環境を得ることができた
  • ProtojureでgRPCのストリーミングはcore.asyncのチャネルを介して実現できると分かった
  • ClojureでgRPCのAPIを開発するならProtojureはかなり良い選択肢になりそう(実用に向けてさらに詳細を探ってみたい)
  • やはりClojureは超楽しい>ω</

Further Reading

Clojure/ClojureScript関連リンク集 > Webサーバサイド (Clojure)

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
4