TOYOKUMO Tech Blog

トヨクモ株式会社の開発本部のブログです。

ClojureのWeb開発でもっとも重要なRing Handlerについて理解する

こんにちは。開発本部の @makinoshi です。

トヨクモではメインの開発言語としてClojureを採用しています。 Clojureの文法は簡潔でデータ型も少なく、比較的覚えやすい言語だと思います。

一方でWeb開発においては、Symfony/LaravelRuby on Railsのような大きなフレームワークに乗るのではなく、 1つのことをうまくやるライブラリを組み合わせてアプリを作っていくという文化であるため、 それぞれを組み合わせていくための知識が必要になります。

その知識は一見すると膨大に思えるのですが、HTTPそのものの知識を除けば実は理解するのに必要な知識は少なく、RingのHandlerがどういうものなのか理解できれば、 ほぼ理解できたといって過言ではありません。

弊社では新卒で入社した方やアルバイトの学生さんなど、徐々にClojureを書く人が増えていることもあり、 この記事で基本的な部分を理解してもらえるように解説していこうと思います。

サンプルコードのリポジトリはこちらにあります。

Ringとは何か

By abstracting the details of HTTP into a simple, unified API, Ring allows web applications to be constructed of modular components that can be shared among a variety of applications, web servers, and web frameworks.

とあるように、HTTPのリクエスト・レスポンスを抽象化し、 仕様 を定めたものです。

抽象化層であるため、実際のサーバーの実装は他が担当します。 実装を提供するサーバーの例としては次のようなライブラリがあります。

これらのサーバーがRingが定めた仕様に沿ってHTTPリクエストを受け、HTTPレスポンスを返します。

弊社製品ではImmutantを使用しています。

Ring Handlerとは何か

Ring Handlerは単なる関数です。

ただし、Ringが定めた仕様に従ったリクエストマップを引数で受け取り、Ringが定めた仕様に従ったレスポンスマップを返す必要があります。 具体的には次のようなものです。

(defn handler [request]
  {:status 200
   :body "Hello world!"})

Handlerをどう使うか

Ring Handlerは決まった形のInput/Outputを提供する関数だということまでわかりました。

ではそれをどう使うかというと、上述したようにRingの仕様を実装しているサーバーの起動時に引数として渡すだけになります。 ring-jetty-adapter を使った例を示します。

(require 'ring.adapter.jetty)

(defn- handler [request]
  {:status 200
   :body "Hello world!"})

(ring.adapter.jetty/run-jetty handler {:host "localhost"
                                       :port 8080
                                       :join? false})

これをREPLで評価して、HTTPieでHTTPリクエストを投げてみます。

*この記事ではHTTPレスポンスを綺麗に表示させるためにHTTPieを使います。

$ http http://localhost:8080
HTTP/1.1 200 OK
Content-Length: 13
Date: Tue, 02 Jul 2019 08:50:31 GMT
Server: Jetty(9.4.12.v20180830)

Hello world!

きちんとリクエストを受け、レスポンスを返せていることが確認できました。

URLによって違った結果を返すにはどうすればいいのか

ここまでで好きな形のレスポンスを返すWebサーバーができていますが、当然ながらどのURLにリクエストしても同じ結果が返ってきます。

$ http http://localhost:8080/hoge
HTTP/1.1 200 OK
Content-Length: 13
Date: Tue, 02 Jul 2019 08:50:31 GMT
Server: Jetty(9.4.12.v20180830)

Hello world!

URLによって異なった結果を返せないと使い物にならないわけですが、そのためにはリクエストマップの中の:uriの値を使います。

(require 'ring.adapter.jetty)

(defn- handler [{:as req :keys [uri]}]
  (cond
    (= uri "/hello")
    {:status 200
     :body "Hello world!"}

    (= uri "/about")
    {:status 200
     :headers {"Content-Type" "text/plain; charset=utf-8"}
     :body "弊社についてご紹介します。"}

    :else
    {:status 404
     :headers {"Content-Type" "text/plain; charset=utf-8"}
     :body "お探しのページは見つかりませんでした。"}))

(ring.adapter.jetty/run-jetty handler {:host "localhost"
                                       :port 8080
                                       :join? false})
$ http http://localhost:8080/hello
HTTP/1.1 200 OK
Content-Length: 13
Date: Tue, 02 Jul 2019 09:05:05 GMT
Server: Jetty(9.4.12.v20180830)

Hello world!

$ http http://localhost:8080/about
HTTP/1.1 200 OK
Content-Length: 39
Content-Type: text/plain;charset=utf-8
Date: Tue, 02 Jul 2019 09:05:12 GMT
Server: Jetty(9.4.12.v20180830)

弊社についてご紹介します。

$ http http://localhost:8080/foo
HTTP/1.1 404 Not Found
Content-Length: 57
Content-Type: text/plain;charset=utf-8
Date: Tue, 02 Jul 2019 09:05:23 GMT
Server: Jetty(9.4.12.v20180830)

お探しのページは見つかりませんでした。

一応URLによって異なるレスポンスを返すことはできましたが、handlerにいちいち条件を足していくのはやってられないです。

この問題を解決するために、ルーティングライブラリを使います。 ルーティングライブラリには次のようなものがありますが、やっていることはどれも上記のようなことです。

ここでは弊社の製品でも使っているbidiを使った例を示します。

(require 'ring.adapter.jetty)
(require 'bidi.ring)

(defn- hello-handler [req]
  {:status 200
   :body "Hello world!"})

(defn- about-handler [req]
  {:status 200
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "弊社についてご紹介します。"})

(defn- not-found-handler [req]
  {:status 404
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "お探しのページは見つかりませんでした。"})

(def route
  ["/" {"hello" hello-handler
        "about" {:get about-handler}
        true not-found-handler}])

(def handler
  (bidi.ring/make-handler route))

(ring.adapter.jetty/run-jetty handler {:host "localhost"
                                       :port 8080
                                       :join? false})

これで条件分岐をルーティングライブラリにやってもらいつつ、コードを綺麗にすることができました。

*ここで bidi.ring/make-handler は関数を返す関数であることに注意してください。

Middlewareとは何か

ルーティングライブラリを使うことでhandler関数を分けることができましたが、今度はhandlerで共通な処理を含めたいときに困るようになりました。

Ringにはhandlerの処理の前後に任意の処理を実行する概念としてRing Middlewareがあります。

とは言え複雑なものではなく、引数でhandler(関数)を受け取り、新しいhandler(関数)を作って返すだけです。

(require 'ring.adapter.jetty)
(require 'bidi.ring)

(defn- hello-handler [req]
  {:status 200
   :body "Hello world!"})

(defn- about-handler [req]
  {:status 200
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "弊社についてご紹介します。"})

(defn- not-found-handler [req]
  {:status 404
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "お探しのページは見つかりませんでした。"})

(def route
  ["/" {"hello" hello-handler
        "about" {:get about-handler}
        true not-found-handler}])

(defn- hoge-middleware
  "/hoge以外へのリクエストかつレスポンスが404ならいつもhogeを返すようにする"
  [handler]
  ; 関数が戻り値
  (fn [{:as req :keys [uri]}]                        ; リクエストマップの値を使える
    (let [req (assoc-in req [:params :hoge] "HOGE!") ; リクエストマップの値を自由に書き換えられる
          {:as res :keys [status]} (handler req)     ; ここで引数のhandler(= 元々のhandler)を実行
          ]                                          ; handlerの結果(= レスポンスマップ)に手を加えられる
      (if (and (= status 404) (not= uri "/hoge"))
        {:status 200
         :body "HOGE!"}
        res))))

(def handler
  (-> (bidi.ring/make-handler route) ; handlerを作り、
      (hoge-middleware)              ; middlewareの引数として渡す
      ))

(ring.adapter.jetty/run-jetty handler {:host "localhost"
                                       :port 8080
                                       :join? false})
$ http http://localhost:8080/foo
HTTP/1.1 200 OK
Content-Length: 5
Date: Tue, 02 Jul 2019 09:57:05 GMT
Server: Jetty(9.4.12.v20180830)

HOGE!

APIでJSONを返すにはどうしたらいいのか

最後にRing Middlewareのライブラリを使ってJSONを返すAPIを作ってみます。

そのためにMuuntajaを使います。 MuuntajaはJSONだけでなく様々なデータ形式に対応しており、リクエストされたContent-Typeに対応したレスポンスにしてくれるというとても便利なライブラリです。

(require 'ring.adapter.jetty)
(require 'bidi.ring)
(require 'muuntaja.middleware)

(defn- hello-handler [{:as req :keys [body-params]}]
  {:status 200
   :body body-params}) ; リクエストされたbodyの値をそのまま返すだけ

(defn- about-handler [req]
  {:status 200
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "弊社についてご紹介します。"})

(defn- not-found-handler [req]
  {:status 404
   :headers {"Content-Type" "text/plain; charset=utf-8"}
   :body "お探しのページは見つかりませんでした。"})

(def route
  ["/" {"hello" hello-handler
        "about" {:get about-handler}
        true not-found-handler}])

(defn- hoge-middleware
  "/hoge以外へのリクエストかつレスポンスが404ならいつもhogeを返すようにする"
  [handler]
  ; 関数が戻り値
  (fn [{:as req :keys [uri]}]                        ; リクエストマップの値を使える
    (let [req (assoc-in req [:params :hoge] "HOGE!") ; リクエストマップの値を自由に書き換えられる
          {:as res :keys [status]} (handler req)     ; ここで引数のhandler(= 元々のhandler)を実行
          ]                                          ; handlerの結果(= レスポンスマップ)に手を加えられる
      (if (and (= status 404) (not= uri "/hoge"))
        {:status 200
         :body "HOGE!"}
        res))))

(def handler
  (-> (bidi.ring/make-handler route) ; handlerを作り、
      (hoge-middleware)              ; middlewareの引数として渡す
      (muuntaja.middleware/wrap-format) ; 追加
      ))

(ring.adapter.jetty/run-jetty handler {:host "localhost"
                                       :port 8080
                                       :join? false})
# リクエストも表示させてみます
$ http -v POST http://localhost:8080/hello foo=bar
POST /hello HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 14
Content-Type: application/json
Host: localhost:8080
User-Agent: HTTPie/1.0.2

{
    "foo": "bar"
}

HTTP/1.1 200 OK
Content-Length: 13
Content-Type: application/json;charset=utf-8
Date: Tue, 02 Jul 2019 10:11:05 GMT
Server: Jetty(9.4.12.v20180830)

{
    "foo": "bar"
}

おわりに

ここまでで、RingのHandlerとMiddlewareについてご理解いただけましたでしょうか。 これらが理解できれば、あとはWebアプリケーションに共通して必要なMiddlewareを足していけば、すぐに通常のWebアプリケーションに必要な機能が揃います。

次回以降、定番のMiddlewareについて解説する記事も書いていこうと思います。


トヨクモ株式会社ではClojureを書きたいエンジニア、PHP/Vue.jsを書きたいエンジニア、技術が好きなエンジニアを募集しております。

よろしければ採用ページをご覧ください。

kintone REST API向けのPHPのクエリビルダを作りました

開発本部の齊藤です。kintone REST API向けのPHPのクエリビルダkintone-query-builderを作成しました。 レコードの取得 (GET) にある、「レコードの一括取得(クエリで条件を指定)」のパラメータqueryのためのクエリビルダーです。 ソースコードはMITライセンスで公開してありますので、kintone連携製品の開発者の方はよかったら使ってみてください。 packagistに登録してありますので、composer require toyokumo/kintone-query-builderで使うことができます。

ユースケース

サイボウズスタートアップスでは、kViewerで実際に使用しています。既存のパラメータqueryを文字列連結で地道に組み立てるモジュールを、kintone-query-builderで書き直しました。リファクタリングの結果として、より可読性の高いコードにすることができました。改変してありますが、クエリビルダー導入前のコードと、導入後のコードを比較してみたいと思います。

比較

以前のコード

<?php
foreach ($filters as $filter) {

    $with = $filter['conj'] ?? 'and';
    if (substr($query, -1) === '(') {
        $with = '';
    }
    $query .= " {$with} " . $this->makeQuery($filter);
}

この if(substr($query,-1)==='(')は今まで貯めてきたクエリがあるかどうかを判定していますが、こういった地道な文字列操作をなくすことができました。

<?php
foreach ($filters as $filter) {

    if ($with === 'and') {
        $builder->andWhere($subBuilder);
    } else {
        // $with=='or'
        $builder->orWhere($subBuilder);
    }
}

また、kintone apiの仕様では、time = NOW()のような構文で関数を使うことができるのですが、クエリビルダを使用しない場合、右辺の値が関数かどうかを調べるコードが必要になります。

<?php
        if (
            preg_match(
                '/^(NOW\(\))|(TODAY\(\))|.../',
                $filter['val']
            )
        ) {
            $val = $filter['val'];
        } else {
            $val = "\"{$filter['val']}\"";
        }

この処理はクエリビルダがやってくれるので、すべて消すことができました。

例(レコードの全取得)

kintone apiの仕様として、1回のリクエストにつき500件のレコードしか取得できない、という制約があります。kintone query builderを使用して特定の上限を満たすレコードを全取得するコードは以下になります。

<?php
$builder = (new KintoneQueryBuilder())->where(...);
$records = $api->fetch($builder.build());
// do something
$offset = 0;
$records_max = 500;
while(!\empty($records)) {
    // do something
    $offset+=$records_max;
    $records = $api->fetch($builder->offset($offset)->build());
}

offsetの指定が違うだけで、条件指定の部分が同じクエリを複数発行することになりますが、$builderを使いまわすことができています。

詳しい使い方

READMEを引用します。

<?php
use KintoneQueryBuilder\KintoneQueryBuilder;
use KintoneQueryBuilder\KintoneQueryExpr;
// example
// すべての演算子(=, !=, like, not like, <, >, <=, >=, in, not in)が使えます
(new KintoneQueryBuilder())->where('name', '=', 'hoge')->build();
// => 'name = "hoge"'
(new KintoneQueryBuilder())
    ->where('favorite', 'in', ['apple', 'banana', 'orange'])
    ->build();
// => 'favorite in ("apple","banana","orange")'
(new KintoneQueryBuilder())
    ->where('age', '>', 10)
    ->andWhere('name', 'like', 'banana') // かわりにwhereと書くことができます(where = andWhere)
    ->andWhere('name', '!=', 'banana')
    ->build();
// => 'age > 10 and name like "banana" and name != "banana"'
(new KintoneQueryBuilder())
    ->where('age', '>', 20)
    ->orderBy('$id', 'desc')
    ->limit(50)
    ->build();
// => 'age > 20 order by $id desc limit 50'
(new KintoneQueryBuilder()) // ネストしたクエリには、KintoneQueryExprを$builder->whereの引数として渡してください。
->where(
    (new KintoneQueryExpr())
        ->where('a', '<', 1)
        ->andWhere('b', '<', 1)
)->orWhere(
    (new KintoneQueryExpr())
        ->where('c', '<', 1)
        ->andWhere('d', '<', 1)
)->build();
// => '(a < 1 and b < 1) or (c < 1 and d < 1)'
(new KintoneQueryBuilder())->where('x', '=','ho"ge')->build()
// ダブルクオートはエスケープされます
// => 'x = "ho\"ge"'

今後も継続的にメンテナンスしていきます。また、今後JavaとJavascriptへポートする予定です。バグがありましたら、GitHubリポジトリのIssueで報告していただけるとありがたいです。

与太話

kintoneのapiの仕様が明確に書かれていないところがあり、テスト環境でクエリを投げてapiと対話しながら、仕様を明確にしていく作業が必要でした。 ダブルクオートのエスケープが必要であること、order byを複数のカラムについてかける方法(ordrer by x desc, y asc などとする)、like検索の仕様など、いくつか気になる点がありました。 ドキュメント のコメント欄を漁る必要があり調べるのが手間だったので、kintone本体の開発者の方には、コメントの内容を必要であればドキュメントにも追加していただきたいです。