gRPC Services

gRPC is a “high performance, open-source universal RPC framework”.

For those without any prior gRPC experience, gRPC is a standardized way of communicating between processes, often over a network, whether within a data center or across the wider internet.

Below is a simple gRPC service definition:

syntax = "proto3";
package com.example.addressbook;

message Person {
    string name = 1;
}

message AddressBook {
    repeated Person people = 1;
}

message HelloResponse {
    string message = 1;
}

service Greeter {
    rpc Hello (Person) returns (HelloResponse);
}

The service definition defines an endpoint (often reachable at some well-known URL or IP), called Greeter. The Greeter service exposes a method called Hello. We may interact with the Hello method by contacting the Greeter service and sending a Person message. Refer to Protocol Buffers above for a walkthrough of protobuf with protojure.

The message definition of HelloResponse is just like the message Person definition discussed in the previous section.

For a gRPC quick-start, open a new terminal and run:

lein new protojure demo-server
cd demo-server && make all
lein run

You should now have a gRPC server running at http://localhost:8080. We will use this endpoint for further exploration.

gRPC Client

With the gRPC server running as directed above, open a separate terminal and cd to a directory of your choice. Copy the entire protobuf defined above into your current directory as greeter.proto

Next, run:

protoc --clojure_out=grpc-client:. greeter.proto

If we check the contents of our directory, we will now also see a folder called com/. Inside is our generated gRPC client code.

$ tree
.
├── com
│   └── example
│       ├── addressbook
│       │   └── Greeter
│       │       └── client.cljc
│       └── addressbook.cljc
└── greeter.proto

We note that we passed the option grpc-client to the compiler. The gRPC code generation for clients and servers are optional in Protojure. A similar grpc-server option exists for server-side deployments.

Next, create another file called project.clj in our current directory with contents:

(defproject protojure-tutorial "0.0.1-SNAPSHOT"
  :description "FIXME: write description"
  :url "http://example.com/FIXME"
  :license {:name "Apache License 2.0"
            :url "https://www.apache.org/licenses/LICENSE-2.0"
            :year 2022
            :key "apache-2.0"}
  :dependencies [[org.clojure/clojure "1.10.3"]

                 ;; -- PROTOC-GEN-CLOJURE --
                 [io.github.protojure/grpc-client "2.0.1"]
                 [io.github.protojure/google.protobuf "2.0.0"]]
  :source-paths ["."])

Save it, and run a REPL

$ lein repl
nREPL server started on port 34903 on host 127.0.0.1 - nrepl://127.0.0.1:34903
WARNING: cat already refers to: #'clojure.core/cat in namespace: net.cgrand.regex, being replaced by: #'net.cgrand.regex/cat
REPL-y 0.3.7, nREPL 0.2.12
Clojure 1.10.0
OpenJDK 64-Bit Server VM 1.8.0_222-8u222-b10-1ubuntu1~18.04.1-b10
    Docs: (doc function-name-here)
          (find-doc "part-of-name-here")
  Source: (source function-name-here)
 Javadoc: (javadoc java-object-or-class-here)
    Exit: Control+D or (exit) or (quit)
 Results: Stored in vars *1, *2, *3, an exception in *e

Next, require the generated com.example.addressbook.Greeter.client namespace, we will see output similar to the below:

user=> (require '[com.example.addressbook.Greeter.client :as greeter])
nil

We can now see that one of the var’s refer’d into our REPL is greeter/Hello:

user=> greeter/He<Tab for auto complete options>
Hello

In order to invoke the client call, we’ll need to create a client. We do this by requiring the protojure-lib ns below:

user=> (require '[protojure.grpc.client.providers.http2 :as grpc.http2])
nil

And creating a client connection:

user=> (def client @(grpc.http2/connect {:uri "http://localhost:8080"}))
#'user/client

Note: Many calls in the SDK return a promise and we therefore deref the calls to make them synchronous for illustration purposes.

Now we can use our Hello function from above, and with the protoc-plugin example hello running we will receive a HelloResponse message (you can see this message defined in the greeter.proto content above):

user=> @(greeter/Hello client {:name "Janet Johnathan Doe"})
#com.example.addressbook.HelloResponse{:message "Hello, Janet Johnathan Doe"}

If we go back to the source code of the running server (the output of lein new protojure demo-server above) and apply the below patch (remove the lines marked with - and add the lines marked with +):

diff --git a/src/demo_server/service.clj b/src/demo_server/service.clj
index 51c63f0..b480bec 100644
--- a/src/demo_server/service.clj
+++ b/src/demo_server/service.clj
@@ -8,7 +8,9 @@
             [protojure.pedestal.core :as protojure.pedestal]
             [protojure.pedestal.routes :as proutes]
             [com.example.addressbook.Greeter.server :as greeter]
-            [com.example.addressbook :as addressbook]))
+            [com.example.addressbook :as addressbook]
+            [io.pedestal.log :as log]))

 (defn about-page
   [request]
@@ -40,6 +42,7 @@
   greeter/Service
   (Hello
     [this {{:keys [name]} :grpc-params :as request}]
+    (log/info "Processing com.example.addressbook.Greeter/Hello invocation with request: " name)
     {:status 200
      :body {:message (str "Hello, " name)}}))


Stop the running demo-server process and restart with lein run.

From your client repl, you can now re-run:

user=> (def client @(grpc.http2/connect {:uri "http://localhost:8080"}))
#'user/client
user=> @(greeter/Hello client {:name "Janet Johnathan Doe"})
#com.example.addressbook.HelloResponse{:message "Hello, Janet Johnathan Doe"}

After invoking the client call against the demo-server above, viewing the logs of the lein run demo-server will show:

20-07-08 12:39:18 mrkiouak INFO [demo-server.service:116] - {"Processing com.example.addressbook.Greeter/Hello invocation with request: " "Janet Johnathan Doe", :line 44}

Congratulations, you’ve invoked a remote procedure call round trip with the GRPC protocol using Clojure on both ends. You may now interoperate with a client or server written in any other language that adheres to the GRPC and .proto spec.

Further examples

Client Connect Example
@(grpc.http2/connect {:uri (str "http://localhost:" port) :content-coding "gzip"})
Unary Example

Protocol Buffer Definition

syntax = "proto3";
package com.example.addressbook;

message Person {
    string name = 1;
    int32 id = 2;  // Unique ID number for this person.
    string email = 3;

    enum PhoneType {
        MOBILE = 0;
        HOME = 1;
        WORK = 2;
    }

    message PhoneNumber {
        string number = 1;
        PhoneType type = 2;
    }

    repeated PhoneNumber phones = 4;
}

message HelloResponse {
    string message = 1;
}

  • gRPC Service Definition
service Greeter {
    rpc Hello (Person) returns (HelloResponse);
}
  • Client
@(greeter/Hello @(grpc.http2/connect {:uri "http://localhost:8080"}) {:name "Janet Johnathan Doe"})
  • Server Handler
(deftype Greeter []
  greeter/Service
  (Hello
    [this {{:keys [name]} :grpc-params :as request}]
    {:status 200
     :body {:message (str "Hello, " name)}}))

Include the below in the interceptors passed to the pedestal routes key:

(proutes/->tablesyntax {:rpc-metadata greeter/rpc-metadata
                        :interceptors common-interceptors
                        :callback-context (Greeter.)})

Refer to src/hello in the hello example in the examples/ dir of protoc-plugin here

You can find an additional unary client and server example (a runnable one) in the boilerplate generated by ‘lein new protojure’

Server Streaming Example

When a client sends a request to the server, two channels are provided in the request: :grpc-out and close-ch.

  • :grpc-out channel

Is the streaming channel, used to send all the messages. The handler first acknowledges streaming will start by returning the same grpc-out channel as the :body of the response map (instead of a map as above in the unary example).

When the server is done with the streaming, simply close! the channel:

(deftype Greeter []
  greeter/Service
  (SayRepeatHello
    [this {{:keys [name]} :grpc-params :as request}]
    (let [resp-chan (:grpc-out request)]
      (go
        (dotimes [_ 3]
          (>! resp-chan {:message (str "Hello, " name)}))
        (async/close! resp-chan))
      {:status 200
       :body resp-chan})))
  • close-ch channel

Sometimes the client disconnects before expected. The server gets notified of such events via this channel. When this happens, server needs to handle it accordingly:

(defn handle-client-disconnect [close-chan]
  (async/take! close-chan
               (fn [signal]
                 (log/info "do stuff to handle client disconnection"))))

(deftype Greeter []
  greeter/Service
  (SayRepeatHello
    [this {{:keys [name]} :grpc-params :as request}]
    (let [close-chan (:close-ch request)
          resp-chan (:grpc-out request)]
      (handle-client-disconnect close-chan)
      (go
        (dotimes [_ 3]
          (>! resp-chan {:message (str "Hello, " name)}))
        (async/close! resp-chan))
      {:status 200
       :body resp-chan})))

  • Error handling

Maybe the server needs to return an error to the client for any reason. This can be accomplished by using the [grpc-statuses] (https://github.com/protojure/lib/blob/master/src/protojure/grpc/status.clj):

(defn valid? [name]
  ;do validation
  )

(deftype Greeter []
  greeter/Service
  (SayRepeatHello
    [this {{:keys [name]} :grpc-params :as request}]
    (let [resp-chan (:grpc-out request)]
      (when-not (valid? name)
        (grpc.status/error :invalid-argument "Invalid parameter."))
      (go
        (dotimes [_ 3]
          (>! resp-chan {:message (str "Hello, " name)}))
        (async/close! resp-chan))
      {:status 200
       :body resp-chan})))

The error path (when “name” is not valid) will throw a java.util.concurrent.ExecutionException exception, that needs to be handled properly in the client side, while trying to [deref] (https://clojuredocs.org/clojure.core/deref) the promise that was returned on the request:


(try
  @(greeter/SayRepeatHello client {:name "Invalid name"} (async/chan 1)
  (catch Exception e
    (log/warn (format "promise compĺeted with error: %s" (:message (ex-data (.getCause e)))))))
Client Streaming Example

Identical to the above Client example for unary – instead of closing the channels after pushing a single map, keep the core.async channel open and push maps as needed.

See the streaming-grpc-check test in Protojure lib’s grpc_test.clj

Excerpt:

    (let [repetitions 50
          input (async/chan repetitions)
          output (async/chan repetitions)
          client (:grpc-client @test-env)
          desc {:service "example.hello.Greeter"
                :method "SayHelloOnDemand"
                :input {:f new-HelloRequest :ch input}
                :output {:f pb->HelloReply :ch output}}]

      (async/onto-chan input (repeat repetitions {:name "World"}))

      @(-> (grpc/invoke client desc)