Toyokumo Tech Blog

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

メール送信ライブラリ tarayo のすすめ

開発本部の飯塚です。

今回は担当している製品で利用しているメール送信ライブラリを自社製のものに移行した経緯や利点などをまとめてみました。

概要

  • kMailer というサービスの開発/運用を行っている
  • コア機能であるメール送信のライブラリとして postal を使っていたが問題点がいくつかあった
  • その問題点を解決しているライブラリが他になかったので開発し、移行した

kMailer とは?

kMailer ロゴ

kMailerkintone 内に登録されているデータを利用してメール送信するというサービスです。

コア機能であるメール送信をする方法としては現状、以下2つの方法を提供しています。

  • お客様のSMTPサーバーを利用したメール送信
  • Google との OAuth 連携を利用したメール送信

今回は前者の「お客様のSMTPサーバーを利用したメール送信」で利用しているライブラリの移行の話です。

postal とは?

postal は Clojure でSMTPによるメール送信をしようとした場合の デファクトスタンダードと言っても過言ではないライブラリです。 SMTP の他に sendmail を使ったメール送信にも対応しています。

以下、テスト用メールサーバーを使ってメール送信をする簡単な例です。

(require '[postal.core :as postal])

(postal/send-message {:host "localhost" :port 1025}
                     {:from "alice@example.com"
                      :to "bob@example.com"
                      :subject "hello"
                      :body "postal world"})

postal の問題点

postal の API はとてもシンプルで postal.core/send-message が提供されているのみです。 この関数に接続先のサーバー情報とメールの内容を渡せばそれだけでメール送信をしてくれます。 とても簡単で良いのですがいくつか問題がありました。

  1. 開発が止まっている JavaMail に依存している
    • JavaMail は ver 1.6.2 のリリースを最後に Eclipse Enterprise for Java の一部となり Jakarta Mail と名前が変わり、こちらで開発が続けられています。
    • JavaMail には宛先として不正なメールアドレス(例えば bob@example.com. のように末尾がドットで終わる)を渡しただけでも NullPointerException を投げる問題があり、後継の Jakarta Mail を使いたいという要望がありました。
      • Jakarta Mail の場合 javax.mail.internet.AddressException 例外が Domain ends with dot というメッセージと共に投げられるのでとても親切です。
  2. 一度に複数のメールを送った場合のエラー時の挙動
    • 複数のメールを送信する場合、単純に doseq でループを回しているだけなので、途中で前述のような NullPointerException が投げられてしまうとそれ以降のメール送信は止まってしまいます。
      • その上、リクエスト処理の結果は doseq 内で捨てられてしまっているため、どこまで送信リクエストが完了したのかもわかりません。
  3. メールサーバーとの接続/切断が暗黙的
    • 複数のメールを一度に送信する場合でメール単位の結果を取得しようとすると、メール単位で postal.core/send-message を呼び出すしかないのですが、メールサーバーとの接続/切断は関数内で閉じられてしまっています
      • そのため、例えば Gmail (smtp.gmail.com) を使ったメールの一斉送信をしようとすると、メールサーバーへの接続/切断を繰り返しすぎて Too many login attempts エラーが簡単に発生してしまいます。

後者2つの問題解決のためのプルリクエストは出したもののマージには至らなかったので、kMailer では postal をフォークして改修したバージョンを製品に使うという中途半端な状態が続いていました。

tarayo とは?

tarayo は postal での問題点を解決するために弊社が開発した SMTP クライアントライブラリです。 名前の由来は郵便局の木として知られるタラヨウからです。

postal との主な違いは以下の通りです。

  • Jakarta Mail ベース
  • SMTP のみをサポート (単一機能のみを提供)
  • メールサーバーとのコネクションが明示的

以下、postal でのメール送信例を tarayo を使って書き換えたものです。

tarayo ではメールサーバーへの接続(tarayo.core/connect)とメール送信(tarayo.core/send!)を別々の関数として提供しているので、 postal ほど簡単ではありません。(with-open しているだけなので十分簡単ではありますが)

(require '[tarayo.core :as tarayo])

(with-open [conn (tarayo/connect {:host "localhost" :port 1025})]
  (tarayo/send! conn {:from "alice@example.com"
                      :to "bob@example.com"
                      :subject "hello"
                      :body "tarayo world"}))

tarayo でどう postal の問題点を解決しているか

では postal にあった問題点を tarayo ではどうやって解決しているかを簡単にまとめてみました。

  1. 開発が止まっている JavaMail に依存している
    • tarayo は最初から Jakarta Mail ベースで開発しています。
  2. 一度に複数のメールを送った場合のエラー時の挙動
    • tarayo.core/send! は1通のメール送信だけを扱う関数としています。
      • そのため複数のメールを一度に送る場合はライブラリの利用者側でループする必要はあります。
      • 1通の送信だけが対象なので、結果は勿論捨てずに返しており、送信対象と結果の紐付けも容易です。
  3. メールサーバーとの接続/切断が暗黙的
    • メールサーバーへの接続をするだけの関数として tarayo.core/connect を提供しています。
      • これにより with-open マクロなどを使って明示的に接続を切る必要は出てきますが、切断するタイミングを任意に決められるので一斉送信などをする場合により柔軟な対応が可能になっています。

kMailer で利用する上での問題点の解決を第一に開発したライブラリではありますが、なかなか使い勝手の良い出来になっているかと思います。

最後に

kMailer ではすでに postal から tarayo への移行が完了しています。 平日1日で数万通のメールを送っていて、これらがすべて tarayo でカバーされるメールです。

接続先のメールサーバーはお客様の設定次第であり多岐にわたるため、postal から tarayo への移行後に接続できなくなるメールサーバーが出ないかの不安はありました。 しかし今のところそういった問題は見つかっておらず健気にメール送信処理を頑張ってくれています!

このようにある程度の規模での利用実績も積めたので、もし Clojure でメール送信をする機会がありましたら一度 tarayo を試してみていただければなと思います。


トヨクモでは一緒に働いてくれるエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。

AWS CLI を Babashka スクリプトでラップして楽しよう

開発本部の飯塚です。

業務で Babashka を使う機会を無理矢理作ってみたので、その副産物を公開してみます。

動機

  • CloudWatch Logs からログを抽出して調査したい
  • AWS CLI を使ってもいいが、クエリの発行、クエリの完了待ち、結果取得が面倒
  • 良い感じにラップしたスクリプトを用意して誰でも簡単に使えるようにしたい
  • Clojure で!!

Babashka

Babashka@borkdude 氏が開発している Clojure のコードを スクリプト として高速に実行できる環境です。 高速さの背景には GraalVM を利用したネイティブイメージ化があります。 これにより JVM 言語で度々話題にあげられるスタートアップタイムがほぼ無視できるくらいの速度で起動してくれるのが大きな特徴です。

$ time clojure -e '(+ 1 2 3)'
6
clojure -e '(+ 1 2 3)'  1.47s user 0.10s system 195% cpu 0.806 total

$ time bb '(+ 1 2 3)'
6
bb '(+ 1 2 3)'  0.00s user 0.01s system 23% cpu 0.070 total

今回はこの Babashka を使って AWS CLI をラップしたスクリプトを作ってみました。

前提

この記事で紹介しているコードは使い方は以下が済んでいることを前提としています。

  • AWS CLI がインストールされていること
  • aws configure で鍵情報などが設定済みであること

作ったもの

コードが少し長かったので Gist に置いてあります。

AWS CLI wrapping script by Babashka

先頭で require している cheshire.core は Babashka にバンドルされているもので、何もしなくてもデフォルトで使うことができます。 他に何がデフォルトで使えるのかなどは README の Usage に書かれています。

基本的には aws.logs.core/query を使えば AWS CLI を通じてログを取得できるようにしています。 取得するログの範囲は指定した日付の 00:00:00 から翌日の 00:00:00 としています。

もし aws.logs.core/query では自動化されすぎてやりたいことができないという場合は、 aws.logs.core/query から呼んでいる関数群を時前で制御すれば大抵のことはできるように関数を分割しているつもりです。

使い方

まずこれから紹介するコード類は以下のようなディレクトリ構造になっていることを前提とします。

- run.sh
- src
   |
   +- aws
   |   |
   |   +- logs
   |       |
   |       +- core.clj
   +- example
       |
       +- core.clj

最初に aws.logs.core を呼び出すコードです。 以下ではログのメッセージに "NullPointerException" が含まれるものだけを抽出してみています。 フィルタリングなどのクエリについては CloudWatch Logs Insights クエリ構文 を参照してください。

(ns example.core
  (:require
   [aws.logs.core :as logs]))

(defn -main [& args]
  (when (< (count args) 3)
    (System/exit 1))

  (try
    (let [[yyyy mm dd & [region]] args
          filter-query  "filter (@message like \"NullPointerException\")"]
      (doseq [result (logs/query {:group-name "/aws/path/to/your.log"
                                  ;; aws configure しているリージョンとは違うリージョンにしたい場合は指定可能
                                  :region region
                                  :yyyy yyyy :mm mm :dd dd
                                  :queries [filter-query]})]
        (println result))
      (System/exit 0))
    (catch clojure.lang.ExceptionInfo ex
      (println (.getMessage ex) (ex-data ex))
      (System/exit 1))))

次に実行するためのスクリプトです。 無くても実行できますが用意しておくとクラスパスを都度指定する必要がなくなり便利です。

#!/bin/bash
SCRIPT_DIR=$(cd $(dirname $0); pwd)
bb --classpath ${SCRIPT_DIR}/src -m example.core -- $@

あとは以下のように年月日を指定して実行すればログを取得して出力してくれます。 今回の例では取得したログをそのまま出力してしまっていますが、必要に応じて欲しい情報だけピックアップして出力しても良いでしょう(業務で使っているスクリプトではそうしてます)

./run.sh 2112 9 3

最後に

ここで紹介したスクリプトは Clojure を使うことを除けば他の言語では大して目新しくないものでしょう。 このスクリプトの良いところは Clojure で書けるというだけでなく、REPL 駆動で開発できるということが個人的にはかなり大きいです。 取得したログから必要な情報だけピックアップするにしても、REPL 駆動であれば一時的に結果を適当な var に束縛しておいてあれこれ試すことは容易です。

そうして作ったスクリプトがスタートアップタイムを気にせずに実行できるので開発体験としてはとても良いものがあると思います。

もし REPL 駆動での開発に興味があれば Vim で始める Clojure 開発 も併せて参照してください。


トヨクモでは一緒に働いてくれるエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。

Vim で始める Clojure 開発

Vim/Neovim 向けに Clojure の開発環境を提供するプラグイン vim-iced を使って Clojure 開発環境の構築をしてみましょう。 なお vim-iced は個人的に開発している拙作の Vim プラグインで、実際に私が業務で(勝手に)利用しているという実績があります。

前提

  • Vim もしくは Neovim がインストールされていること
    • Vim は 8.1.0614 以降、Neovim は 0.4.0 以降が必要であることにご注意ください
  • Java がインストールされていること
  • macOS もしくは Linux 環境(本記事では Ubuntu を想定)であること
    • Windows 環境は vim-iced 自体がサポートしていないので、VM などで Linux 環境を用意してください

ゴール

本記事の内容を実践したことで以下の状態になっていることをゴールとします。

  • Vim を使った Clojure の開発環境が構築でき、REPL 駆動開発ならびにテスト駆動開発が体験できていること

やること

  • Clojure 実行環境のインストール
  • vim-iced のセットアップ
  • サンプルプロジェクトの作成
  • REPL 駆動開発のお試し

Clojure 実行環境のインストール

Clojure の実行環境は複数ありますが、今回は多くのプロジェクトで使われている Leiningen (ライニンゲン)を使った手順を紹介します。

なにはともあれインストールから始めましょう。

# macOS の場合
brew install leiningen

# Linux の場合
curl -fLo lein https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein
chmod +x lein
sudo mv lein /usr/local/bin   # パスが通っているディレクトリであればどこでも可

インストールができたら簡単に Clojure のコードを実行して試してみましょう。

  • lein repl コマンドを実行して REPL(Read Eval Print Loop) を起動します。
    • 初回は起動に必要なライブラリ類をダウンロードするので時間がかかります。 user=> が出てくるまでしばしお待ちください。
  • user=> が出てきたら Read の状態なので、何かコードを書いて評価(Eval)してみましょう。結果が表示(Print)されるはずです。
    • E.g. (+ 1 2 3 4 5) を評価すると 15 が表示されます。
  • 確認ができたら Ctrl + d を押すか、(exit) を評価して REPL を閉じましょう。

vim-iced のセットアップ

vim-iced は Vim プラグインとして提供しています。そのためプラグインマネージャーを利用していない場合はまずその導入から始めましょう。 すでにプラグインマネージャーを利用している場合は次のセクションは飛ばして構いません。 なお本記事では vim-plug を使った例を紹介します。

vim-plug のインストール

Vim/Neovim で設定ファイルのパスが異なるので vim-plug の配置先も異なることにご注意ください。

# Vim の場合
curl -fLo ~/.vim/autoload/plug.vim --create-dirs \
    https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim

# Neovim の場合
curl -fLo ~/.local/share/nvim/site/autoload/plug.vim --create-dirs \
    https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim

各種プラグインのインストール

Vim/Neovim の設定ファイルを用意します。 Vim の場合は ~/.vimrc、Neovim の場合は ~/.config/nvim/init.vim です。 すでに設定ファイルがある場合は「追加する設定」の部分だけの追加で問題ありません。

# Neovim の場合は先にディレクトリを作成しておく
# mkdir -p ~/.config/nvim

cat <<EOT > ~/.vimrc
set nocompatible
set encoding=utf-8
scriptencoding utf-8
filetype plugin indent on

" ==== 追加する設定 ここから ====
" プラグインのインストール先ディレクトリは必要に応じて変更してください
call plug#begin('~/.vim/plugged')
" 次のうちいずれかが必要
Plug 'ctrlpvim/ctrlp.vim'
"Plug 'junegunn/fzf'
"Plug 'liuchengxu/vim-clap'

" 必須
Plug 'guns/vim-sexp',    {'for': 'clojure'}
Plug 'liquidz/vim-iced', {'for': 'clojure'}
call plug#end()

" vim-iced でのデフォルトキーマップを有効化
let g:iced_enable_default_key_mappings = v:true

" 任意) 見やすさのためスクリーンキャプチャ内では有効にしています
set splitright
let g:iced#buffer#stdout#mods = 'vertical'
let g:iced#buffer#error#height = 5
" ==== 追加する設定 ここまで ====
EOT

上記内容の設定ファイルが作成できたら、Vim/Neovim を起動している場合、一度再起動して :PlugInstall コマンドを実行してください。 もしくはターミナル上から vim -c PlugInstall -c qa を実行するのでも可です。

上記の例だと ~/.vim/plugged 配下に各種プラグインが配置されたことが確認できます。

ls ~/.vim/plugged

iced コマンドにパスを通す

最後に vim-iced が提供している iced コマンドへのパスを通す必要があります。 vim-iced のインストール先が ~/.vim/plugged である場合は以下のコマンドでパスを通してください。

export PATH=$PATH:~/.vim/plugged/vim-iced/bin

なお iced コマンドはインストールディレクトリ配下のファイルを参照するので、すでにパスが通っているところへ iced コマンドをコピーしても正しく動作しませんのでご注意ください。 以下を実行して何かしらバージョン番号が表示されれば正しく動作しています。

iced version

サンプルプロジェクトの作成

ここまでで Vim で Clojure 開発を始められる準備は整いました。 Leiningen を使ってサンプルプロジェクトを作って実際に動かしてみましょう。

lein new hello-iced
cd hello-iced

サンプルプロジェクトのディレクトリ構成を簡単に説明すると以下の通りですが、今回使うのはソースコードのみです。

Dir/File Description
src ソースコードはこちら
test テストコードはこちら
project.clj プロジェクト名、バージョン番号、依存ライブラリなどを記述する設定ファイル

REPL 駆動開発への入り口

まずはソースコードを開きます。Leiningen で作成されたプロジェクトでは プロジェクト名.core という名前空間が用意され、名前空間のドット区切りがそのままディレクトリ/ファイル名に反映されます。 唯一の例外がハイフン(-)で、これはディレクトリ/ファイル名上ではアンダーバー(_)に変換されます。

vim src/hello_iced/core.clj

開いたファイルは以下のようになっているかと思います。

(ns hello-iced.core)

(defn foo
  "I don't do a whole lot."
  [x]
  (println x "Hello, World!"))

REPL への接続

接続とIcedEvalの実行例

ソースコードを開いたらおもむろに :IcedJackIn コマンドを実行してみてください。 iced コマンドにパスが通っていれば、ステータスラインに OK: Leiningen project is detected のようなメッセージが表示され、しばらくすると Connected. が表示されます。 この Connected が出た状態が Vim の裏側で起動している REPL に vim-iced が接続している状態です。

※ 2022/05/27追記 ここから

:IcedJackIn は Vim 内で REPL のプロセスを起動しますが、別途 REPL を起動しておいてそちらに接続する方法もあります。 プロジェクト配下で iced repl コマンドを実行してみてください。 すると lein repl 同様に REPL が起動するので、Vim 上で :IcedConnect コマンドを実行すると別途起動した REPL に接続できます。 こちらの方法だと Vim を終了しても REPL は起動したままなので、REPL を再度立ち上げる必要がないというメリットもあります。

※ 2022/05/27追記 ここまで

試しに :IcedEval (+ 1 2 3) コマンドを実行してみてください。Vim 上で 6 という結果が表示されることが確認できるはずです。

コードを評価してみる

ソース上のコード評価の実行例

次にソースの末尾に (comment (foo "iced")) を追記し、その括弧の中にカーソルを移動して、ノーマルモードで <Leader>et (:IcedEvalOuterTopList)とタイプしてみてください。

これはカーソル配下のトップレベルのフォームを対象に評価することを意味しています。 なお <Leader> はデフォルトでバックスラッシュ(\) なので \et となります。

なおここで comment フォームを使っているのは、名前空間 hello-iced.core が読み込まれたときに無駄に評価されないようにするためです。 vim-iced の <Leader>et ではデフォルトで comment フォーム内のコードを評価するようになっているので、既存のコードを評価して試してみたいときには comment フォームを使うと便利です。

(ns hello-iced.core)

(defn foo
  "I don't do a whole lot."
  [x]
  (println x "Hello, World!"))

(comment (foo #_カーソルはここ "iced"))

結果はどうでしょうか?恐らく期待とは違い nil が表示されたかと思います。 vim-iced では関数の戻り値を Popup ならびにステータスラインに表示して、標準出力は別の場所に表示するようにしています。 ノーマルモードで <Leader>ss (:IcedStdoutBufferOpen) とタイプすると標準出力の表示先(以下、StdoutBuffer)が別ウインドウで表示され、期待した結果が出力されていることが確認できると思います。

変更内容を即座に確認する

ソースを修正しその内容を即座に確認する実行例

ではサンプルプロジェクトの foo 関数で println の引数を以下のように変更してみましょう。 この変更で StdoutBuffer に出力される内容が期待したものに変更されるかを確認したいと思います。

(defn foo
  "I don't do a whole lot."
  [x]
  (println x "is awesome!!!")) ;; 出力内容を修正

変更できましたか? できたら foo 関数の中にカーソルを移動して <Leader>etfoo 関数を再評価してください。 REPL 駆動開発では起動している REPL 内に評価された結果が保持されて、それを使って開発を進めていきます。 なので単にコードを変更しただけでは REPL 内の foo は変更前のままなので再度評価してあげる必要があります。

再評価できたら末尾の comment フォームを改めて <Leader>et で評価してみてください。 StdoutBuffer に変更後の内容が出力されることが確認できるかと思います。

このようにフォーム単位で簡単・迅速に挙動が確認できるのがREPL駆動開発の強みです。

上記の例では関数単位ですが、さらに言うと例えば (def x "bar") を評価しておいて、 println フォーム内で <Leader>ee (参考: Evaluation ranges)で評価すると関数内のフォーム単位でも動作確認可能です。

テスト駆動開発への入り口

次にテスト駆動開発に触れてみましょう。 ここでは整数のリストを渡して、その合計値を返すという簡単な関数を題材としてみます。

まずは core.clj に以下の関数を書いて評価してください。 この時点では合計を算出する処理はまだ書かずにとりあえず 0 を返すだけです。

(defn sum [ls]
  0)

テストの作成

テストの実行例

では hello-iced.core/sum のテストを書いてみましょう。 テストコードはサンプルプロジェクト直下の test ディレクトリ配下にあります。

ファイルを探して開いても良いのですが、ここでは :IcedCycleSrcAndTest コマンドを実行してみましょう。 これによりソースファイルに対応するテストファイルとして test/hello_iced/core_test.clj を自動的に開くことができます。(ファイルが実在しなくても名前空間からパスを推測して新規作成も可能です)

開いたファイルは以下のようになっているかと思います。

(ns hello-iced.core-test
  (:require [clojure.test :refer :all]
            [hello-iced.core :refer :all]))

(deftest a-test
  (testing "FIXME, I fail."
    (is (= 0 1))))

サンプルプロジェクトでは必ず失敗するテストしか書かれていないので、この a-test は以下のように書き換えてください。

(deftest sum-test
  (is (= 6 (sum [1 2 3])))
  (is (= 10 (sum [1 2 3 4]))))

ここでは hello-iced.core/sum[1 2 3] と、[1 2 3 4] というリストを渡して、 それぞれの結果が期待したものになるかをテストしています。 このフォーム上にカーソルを移動させてノーマルモードで <Leader>tt (:IcedTestUnderCursor)とタイプしてみてください。 これはカーソル配下のトップレベルのフォームを対象にテストを実行することを意味しています。

ステータスラインにテスト結果の概要が表示されると共に、別ウインドウで期待した結果と実際の戻り値が異なるエラーが2つ表示されることが確認できるかと思います。

ソースの修正

テストの再実行の例

ではテストが通るようにソースを修正していきましょう。 改めて :IcedCycleSrcAndTest コマンドを実行してソース・ファイルに戻りましょう。

まずはすこしズルをして以下のように sum を修正してみてください。

(defn sum [ls]
  6)

修正できたら <Leader>et で再度 sum を評価し、 テストも再実行します。 もう1度 :IcedCycleSrcAndTest で移動、 <Leader>tt をタイプとしても良いのですが、ちょっと面倒なので今度はソースコードを開いたままノーマルモードで <Leader>tr (:IcedTestRedo)とタイプしてみてください。

これは直前に失敗したテストを再実行することを意味しています。 すると今まで2つ出ていたエラーが1つになったかと思います。 あと1つのエラーが残っていますが、今度はズルをせずにきちんと書きましょう。

(defn sum [ls]
  (apply + ls))

再度評価してもう1度 <Leader>tr でテスト実行してみてください。 今まで別ウインドウで表示されていたエラーはウインドウと共に消えて、ステータスラインに成功した旨が表示されたかと思います。

ここまででテストの書き方、実行方法、そしてテスト駆動による修正方法がわかったかと思います。

リンター/フォーマッター の設定

※ 2022/06/01追記

REPL駆動以外にも開発時にあって欲しいのはリンターとフォーマッターのサポートです。 より具体的には以下の2点は開発において必須と言っても過言ではないかと思います。

  • clj-kondo による静的解析が常にかかる
  • cljstyle によるフォーマットがファイル保存時に行われる

clj-kondo による静的解析の設定

Vim においてリンターの結果を表示するためのプラグインはいくつかあり、デファクトスタンダードと言えるものはありません。

主要なプラグインに関しては clj-kondo の以下のドキュメントにまとまっているので、こちらを参考に設定すると良いでしょう。

github.com

なお筆者はこのドキュメントにおける coc.nvim の設定を採用しています。

cljstyle によるフォーマットの設定

フォーマッターについては vim-iced 自体がデフォルトで cljfmt を使ったフォーマットに対応しています。 しかしチーム内でコーディングスタイルを統一したい目的で cljfmt 以外を使いたいニーズもあり cljstyle を使ったフォーマットも勿論サポートしています。

https://liquidz.github.io/vim-iced/#formatting_customize

vim-iced でのフォーマッターの設定

vim-iced にてフォーマッターとして cljstyle を使いたい場合の設定は簡単で ~/.vimrc などに以下の1行を追加するのみです。

let g:iced_formatter = 'cljstyle'

もし cljstyle がインストールされていない場合は :IcedFormat のようなフォーマットに関するコマンドを実行することで 「iced コマンドにパスを通す」でパスを通したディレクトリ配下にインストールするかという案内が始まるので、それに従うと簡単にインストールすることが可能です。

保存時にフォーマットする設定

vim-iced が提供するフォーマット関連のコマンドは基本的に非同期でバッファ上のテキストを更新しますが、保存時は非同期にしてしまうとフォーマット結果が正しくファイルに保存されなくなってしまいます。 そのため vim-iced では同期的に動くフォーマットコマンドも用意しています。

これらのコマンドを BufWritePre に対する autocmd で使うことで保存時のフォーマットを実現できます。

以下ドキュメントからの抜粋ですが設定例です。

https://liquidz.github.io/vim-iced/#format_on_writing_files

aug VimIcedAutoFormatOnWriting
  au!
  au BufWritePre *.clj,*.cljs,*.cljc,*.edn execute ':IcedFormatSyncAll'
aug END

なお環境やフォーマット対象のコードによってはバッファ全体のフォーマットに時間がかかり Vim がフリーズする時間ができてしまうこともなきにしもあらずです。 その場合は :IcedFormatSyncAll の代わりに :IcedFormatSync コマンドを使うとカーソル配下のフォームだけを対象にフォーマット可能です。 フォームを編集 → 保存をこまめに行っている場合だとこちらの設定でフリーズはほぼ回避できますが、保存時のフォーマット対象がカーソル配下のフォームに限定されるのでフォーマット漏れが出る可能性があることに注意してください。

最後に

最初に掲げたゴールを振り返ってみましょう。

Vim を使った Clojure の開発環境が構築でき、REPL 駆動開発ならびにテスト駆動開発が体験できていること

簡単ではありましたがゴールの通りの体験ができたでしょうか? これをきっかけとして Clojure での開発に興味をもっていただけたなら幸いです。

なお vim-iced 自体にも興味を持っていただけたなら、 ここで紹介していない多くの便利な機能がまだあるので、ぜひドキュメントを読んでみていただければと思います。

Spacemacsを利用している方はこちらを参考にしてください。

tech.toyokumo.co.jp


トヨクモでは一緒に働いてくれるエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。

Clojureのフォーマッターcljstyleの使い方

こんにちは。開発本部の江口(@egs33)です。

弊社ではClojure製の製品を開発していますが、ClojureのフォーマッターとしてOSSのcljstyleを利用しています。 利用するにあたって機能の追加やバグの修正などのコントリビュートも行いました。

tech.toyokumo.co.jp 上の記事はフォーマッターの内部実装についての説明ですが、 この記事ではcljstyleの使い方を説明したいと思います。 また、この記事はバージョン0.12.0の情報をもとに書いています。 この記事はバージョン0.15.0の情報をもとに書いています。(2022年6月更新)

Clojureのフォーマッターの現状

近年コードを書いていくときは、golangのgoimportsやJavaScriptのESLintなどを使って共通したフォーマットを当てていくことが広く行われていると思います。 しかし似たようなことをClojureでやろうとした時に実用的なものがない状態が続いていました。

例えば「Clojure formatter」と検索して一番にヒットするのはcljfmtだと思います。 ですがこれはnsマクロの :require のソートをしてくれなかったり、Leiningenプラグインとして使用されることを想定しており、起動時間がかかってしまうため、エディタと連携した使用には耐えられないなどの問題がありました。 そのため、弊社製品にもフォーマッターを自動的に適用できない状態で、レビュー時にインデントを指摘せざるを得ない状況でした。

cljstyleとは

github.com cljstyleは前述したcljfmtをforkして作られた、オープンソースのClojureコードのフォーマッターです。 cljstyle自体もClojureで開発されていて、GraalVMでネイティブバイナリにビルドされているため高速に実行できます。

しかし、0.9.0の時点では弊社の製品に適用するには機能が足りなかったので、次のようなPRを書いて改善した上で、製品に適用しました。

Clojureのスタイルガイドによると、リストのインデントは1つであることが一般的ですが、元々のcljstyleにおいては2つ固定で設定不可能でした。 これを :list-indent-size で設定可能にするというPRを出しました(#21#25)。 また、コメントのインデントに関するバグを修正しました(#32)。

これらのPRがマージされた0.12.0から実際に弊社製品でも使用しています。

;; :list-indent-size 2 (default)
(foo
  bar
  baz)

;; :list-indent-size 1
(foo
 bar
 baz)

実行方法

まずはcljstyleをインストールします。 macの場合はHomebrewから

brew install --cask cljstyle

でインストールすることができます。

LinuxなどHomebrewを使っていない環境では https://github.com/greglook/cljstyle#installation を見てインストールします。

$ cljstyle version # cljstyleのバージョンの表示
mvxcvi/cljstyle 0.15.0 (1f58e2e7af4c193aa77ad0695f6c2b9ac2c5c5ec)
$ cljstyle check # カレントディレクトリ以下のフォーマットのチェック
$ cljstyle fix # カレントディレクトリ以下のフォーマットの修正

どちらも後ろにファイル名をつけることでそのファイルのみのフォーマットを行うことができます。 より詳しい使用方法はcljstyle -hで見ることができます。

cljstyleの設定

cljstyleは .cljstyleファイルにフォーマットの設定を記述します。 .cljstyle ファイルの内容は以下のような設定をClojureのmapとして記述したものです。

{:files {:extensions #{"clj" "cljs" "cljc" "cljx"}
         :pattern nil
         :ignore #{".git" ".hg"}}
 :rules {:indentation {:enabled? true
                       :list-indent 2
                       :indents {}}
         :whitespace {:enabled? true
                      :remove-surrounding? true
                      :remove-trailing? true
                      :insert-missing? true}
         :blank-lines {:enabled? true
                       :trim-consecutive? true
                       :max-consecutive 2
                       :insert-padding? true
                       :padding-lines 2}
         :eof-newline {:enabled? true}
         :comments {:enabled? true
                    :inline-prefix " "
                    :leading-prefix "; "}
         :vars {:enabled? true}
         :functions {:enabled? true}
         :types {:enabled? true
                 :types? true
                 :protocols? true
                 :reifies? true
                 :proxies? true}
         :namespaces {:enabled? true
                      :indent-size 2
                      :break-libs? true
                      :import-break-width 60}}}

ちなみに上の設定は :indent以外はデフォルトのルールです。

.cljstyle ファイルの場所

設定ファイルは各フォルダに対して適用させることができます。 例えば以下のようなフォルダ構成の場合、

a
├── .cljstyle
└── b
    ├── c
    │   ├── .cljstyle
    │   └── foo.clj
    └── d
        ├── .cljstyle
        └── e
            └── bar.clj

foo.clj のフォーマットをする場合には a/b/c/.cljstylea/.cljstyle が使用されます。 深いディレクトリの設定が優先されるのでこの場合はa/b/c/.cljstyle が優先されます。 同様に bar.cljのフォーマットをする場合、a/b/d/.cljstylea/.cljstyle を前者を優先してマージした設定が使用されます。

各設定の内容

以下では :files / :pattern のような表記は設定のmapの :files に対応するvalueの :pattern を示します。

:files / :extensions

デフォルト: #{"clj" "cljs" "cljc" "cljx"}

フォーマットの対象とするファイルの拡張子を指定します

:files / :pattern

デフォルト: nil

フォーマットの対象とするファイルを正規表現で指定します。:files / :extensions の指定と:files / :pattern の指定のどちらかに当てはまったファイルがフォーマットの対象になります。

:files / :ignore

デフォルト: #{".git" ".hg"}

文字列か正規表現の Setでフォーマットの対象としないファイルを指定します。 文字列の場合、一致するファイル名のファイルと、一致するディレクトリ名のディレクトリ以下のファイルは無視されます。 正規表現の場合、パスと一致するファイル、ディレクトリが無視されます。

:rules / :indentation / :list-indent

デフォルト: 2

リストのインデントサイズを指定します。

;; :list-indent-sizeが2の場合
(foo
  bar
  baz)

;; :list-indent-sizeが1の場合
(foo
 bar
 baz)

:rules / :indentation / :indents

コードをフォーマットする際のインデントのルールを指定します。 詳しくは後述します。

:rules / :whitespace / :remove-surrounding?

デフォルト: true

フォームの内部を囲む空白を ( foo ) => (foo) のように削除します。

:rules / :whitespace / :remove-trailing?

デフォルト: true

行の最後にある空白を削除します。

:rules / :whitespace / :insert-missing?

デフォルト: true

要素間に (foo(bar)) => (foo (bar))のように空白を追加します。

:rules / :blank-lines / :trim-consecutive?

デフォルト: true

:rules / :blank-lines / :max-consecutiveで指定される数より多い連続する空行を削除します。 :rules / :blank-lines / :max-consecutiveのデフォルトは 2です。

:rules / :blank-lines / :insert-padding?

デフォルト: true

トップレベルの複数行の非コメントフォーム間に、少なくとも :padding-lines行の空行をもつようにします。 :padding-linesのデフォルトは 2です。

:rules / :eof-newline / :enabled?

デフォルト: true

ファイル末尾に改行がない場合に改行を追加します。

:rules / :comments / :inline-prefix

デフォルト: " " インラインコメント(別のフォームに同じ行で続くコメント)のプリフィックスを指定します。

:rules / :comments / :leading-prefix

デフォルト: "; " コメントだけの行のコメントのプリフィックスを指定します。

:rules / :vars / :enabled?

デフォルト: true

defフォームが複数行になっているときとdoc-stringを持つ場合に改行を入れます。 具体的には以下のようになります。

;; before
(def x (f 1
          2))
(def y "doc" 100)

;; after
(def x
  (f 1
     2))
(def y
  "doc"
  100)

:rules / :functions / :enabled?

デフォルト: true

defndefn-のフォームの内部で改行させます。 また、 fnフォームが複数行にわたっている場合に改行させます。 具体的には以下のようになります。

;; before
(defn f [x] (fn [y] (+ x
                       y)))

;; after
(defn f
  [x]
  (fn [y]
    (+ x
       y)))

:rules / :types / :enabled?

デフォルト: true

defprotocol, deftype, defrecord, reify, proxyなどのフォームの内部で改行させます。 具体的には以下のようになります。

;; before
(defrecord Person [name address])

(defprotocol Bird
  (fly [p])
  (walk [p]))

;; after
(defrecord Person
  [name address])


(defprotocol Bird

  (fly [p])

  (walk [p]))

:rules / :types / :types?

デフォルト: true

deftypedefrecord で内部の改行をさせるかを指定します。

:rules / :types / :protocols?

デフォルト: true

defprotocol で内部の改行をさせるかを指定します。

:rules / :types / :reifies?

デフォルト: true

reify で内部の改行をさせるかを指定します。

:rules / :types / :proxies?

デフォルト: true

proxy で内部の改行をさせるかを指定します。

:rules / :namespaces / :enabled?

デフォルト: true

nsフォームをフォーマットします。 :requireフォームをアルファベット順に並び替えたりします。

;; before
(ns examples.ns
  "example"
  (:import
    java.util.Date
    (java.util.concurrent
      Executors
      LinkedBlockingQueue))
  (:require
    [clojure.set :as set]
    [clojure.string :as s :refer [blank?]]
    [clojure.java.shell :as sh]))

;; after
(ns examples.ns
  "example"
  (:require
    [clojure.java.shell :as sh]
    [clojure.set :as set]
    [clojure.string :as s :refer [blank?]])
  (:import
    java.util.Date
    (java.util.concurrent
      Executors
      LinkedBlockingQueue)))

:rules / :namespaces / :indent-size

デフォルト: 2

リストのインデントサイズを指定します。 :rules / :indentation / :list-indent と同じですが、nsのフォーマットはrequireのソートなど処理が別になっているため、設定も別れています。

:rules / :namespaces / :break-libs?

デフォルト: true

ns フォーム内の :require:import の直後に改行を入れるかを指定します。

;; :break-libs? true
(ns user
  (:require
   [clojure.core.async :as as]
   [clojure.string :as str])
  (:import
   (java.util
    ArrayList
    UUID)))

;; :break-libs? false
(ns user
  (:require [clojure.core.async :as as]
            [clojure.string :as str])
  (:import (java.util
            ArrayList
            UUID)))

:rules / :namespaces / :import-break-width

デフォルト: 60

単一クラスのインポートの文字数が :single-import-break-widthよりも長い場合 グループ化されます。 具体的には以下のようになります。

;; before
(ns example.namespace
  (:import
    java.io.InputStream))

;; after (:single-import-break-width 10)
(ns example.namespace
  (:import
    (java.io
      InputStream)))

インデントの設定

:indentsには以下のようなmapを設定します。

{if [[:block 1]]
 do [[:block 0]]
 fn [[:inner 0]]}

マップのkeyがインデントを適用するフォーム、valueがどのようなルールを適用するかを表します。

デフォルトのルールは cljstyle/indents.clj at main · greglook/cljstyle · GitHubにあります。

インデントを適用するフォーム

マップのkeyにはシンボルと正規表現を指定できます。

  • シンボルの場合
    • foo/barを指定した場合 (foo/bar 1 2)に適用されますが, (bar 1 2)(baz/bar 1 2)には適用されません。
    • fooを指定した場合 (foo 1 2)(a/foo 1 2)に適用されます。名前空間付きのルールがある場合はそちらが優先的に使用されます。
  • 正規表現の場合
    • 名前空間を含むフォームシンボルを文字列としたものにマッチするものに適用されます。 シンボルで指定されたルールがある場合はそちらが優先的に使用されます。

インデントのルール

cljstyleでは :inner, :block, :stair の3種類のルールがあります。 ルールを指定しない場合は通常の関数のインデントとなるので、引数を揃える以下のようなフォーマットになります。

(bar 1 ["a"
        "b"]
     3 4
     5)

(foo
  bar
  baz)

ルールはvectorで [[:inner 0] [:inner 1]]のように指定します。 この場合 [:inner 0][:inner 1]が適用されます。

:inner

:innerルールはパラメータを取り、パラメータで指定された深さのフォームのインデントを2スペースのインデントにします。

;; before
(foo 1
     (bar 2
          3)
     (bar 4
          5)
     [6
      7
      8])

;; after ({foo [[:inner 0]]})
(foo 1
  (bar 2
       3)
  (bar 4
       5)
  [6
   7
   8])

;; after ({foo [[:inner 1]]})
(foo 1
     (bar 2
       3)
     (bar 4
       5)
     [6
      7
      8])

;; after ({foo [[:inner 0] [:inner 1]]})
(foo 1
  (bar 2
    3)
  (bar 4
    5)
  [6
   7
   8])

また、もう一つパラメータを追加することで引数の指定した深さにおいて指定した順番にあるフォームを指定できます。

;; before
(foo (bar1 "1-1"
           "1-2"
           "1-3")
     (bar2 "2-1"
           "2-2"
           "2-3"))

;; after ({foo [[:inner 1 3]]})
(foo (bar1 "1-1"
           "1-2"
       "1-3")
     (bar2 "2-1"
           "2-2"
       "2-3"))

;; after ({foo [[:inner 1 2] [:inner 1 3]]})
(foo (bar1 "1-1"
       "1-2"
       "1-3")
     (bar2 "2-1"
       "2-2"
       "2-3"))

:block

:blockルールはパラメータを1つ取り、改行の前にパラメータで指定された数以下の引数がある場合に :innerルールのようにインデントします。

;; before
(bar
  1
  2
  3)

(bar 1
     2
     3)

(bar 1 2
     3)

(bar 1 2 3)

;; after ({bar [[:block 1]]})
(bar
  1
  2
  3)

(bar 1
  2
  3)

(bar 1 2
     3)

(bar 1 2 3)

:stair

:stairルールはパラメータを1つ取り、基本的に :blockインデントと同じように振る舞いますが、 引数をペアとしてインデントします。

;; before
(cond
  a? :a
  b? :b)

(cond
  a?
  :a
  b?
  :b)

;; after ({cond [[:stair 0]]})
(cond
  a? :a
  b? :b)

(cond
  a?
    :a
  b?
    :b)

上のように condcaseのような条件と式を複数受け取るようなフォームに使うことが想定されています。

Clojure Style Guide用の設定

github.com 以下のように設定すると上のClojure Style Guideに沿ったソースコードに(全てではないですが)フォーマットでき、弊社で利用しています。

{:rules {:indentation {:list-indent 1}
         :blank-lines {:max-consecutive 1
                       :padding-lines 1}
         :vars {:enabled? false}
         :functions {:enabled? false}
         :types {:enabled? false}
         :namespaces {:indent-size 1}}}

エディタとの連携

vim

cljstyle/integrations.md at main · greglook/cljstyle · GitHubにあります。

IntelliJ IDEA (Ultimate)

IntelliJ IDEA (Ultimate)のファイル監視機能で以下のように設定すると 保存時に自動でフォーマットされます。

ClojureScriptのファイルや .cljcファイルなどにも適用する場合は設定のFile TypeをClojureScriptにしたものなどの設定を別で作る必要があります。

最後に

cljstyleの設定方法についてなるべく詳しく説明してみました。 cljstyleでみなさんのClojure lifeがよりよくなることを願っています。

更新履歴

  • 2020/02/17 初版
  • 2022/06/15 cljstyle 0.15.0の設定ファイル形式に対応しました。

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

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

Clojure Ring Middleware大全

これはClojure Advent Calendar 2019の21日目の記事です。

そして、こちらの記事の続きにもなっています。

tech.toyokumo.co.jp

本記事では、RingのMiddlewareについて解説していきます。 基本的に上記の記事を理解いただいた前提で話を進めていこうと思います。

Ring Middlewareについてなぜ解説が必要なのか

まずなぜこのような記事が必要なのかについてです。

Clojureのエコシステムには、基本的にRuby on RailsやDjangoのような重畳なフレームワークはありません。 そのため、Handlerで実行する前に共通的にやっておきたい処理が複数あることは想定されるでしょうが、 それらが自動的に行われるような仕組み、または何かの設定ファイルでONにすればすべて"いい感じ"にやってくれる仕組みなどはありません。

そこで共通処理をHandlerの実行前後に足す仕組みとしてRing Middlewareが使われます。

しかしながらClojureのライブラリの考え方として、1つ1つの関心領域を小さくして、その固有の問題だけを解くようにすると言えるようなものがあります。 それは今すでに慣れている筆者にとっては非常に好ましく思いますし、ぜひClojureでのWeb開発にこれから慣れていく人にも体験して欲しいのもではありますが、 一方で、"標準的な"Webアプリケーションを作ろうと思うと多数のMiddlewareを最初から使う必要があり、必要なものを判断して組み合わせる学習コストがかかってしまいます。 筆者も最初は何がどの効果を発揮しているのかわからず困惑した経験があります。

そこで本記事では、実際に弊社の製品で使われている構成を元に、抑えておくべきと思われるRing Middlewareライブラリと、 その前に仕組みについても解説することで、これからClojureでのWeb開発を始める上でのハードルを下げることを目的としています。

Ring Middlewareとは何か

まず定義としてこのように記述されています。

Middleware are higher-level functions that add additional functionality to handlers. The first argument of a middleware function should be a handler, and its return value should be a new handler function that will call the original handler.

要は、 引数にHandlerを受け取り、新しいHandlerを返す関数ということですね。

ここでおさらいをしておくと、(Ring)Handlerとは、リクエストマップを受け取り、レスポンスマップを返す関数でした。

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

つまり、「引数にHandlerを受け取り、新しいHandlerを返す」ということは上のような関数を受け取って、別の関数を返すということですね。

まずは、何もしない無意味なHandlerを書いてみます。

(defn no-mean-middleware [handler]
  (fn [request]
    (handler request)))

引数で受け取ったHandlerをそのまま使っているだけです。 適用しても何も意味はありませんが、使うにはMiddlewareの引数にHandlerを渡せばいいわけなので、このように書きます。

(no-mean-middleware handler)

もしくはThreading Macroを使えば、

(-> handler
    (no-mean-middleware))

こうなりますね。 これを評価した戻り値である関数をWeb Serverに渡して起動すれば、Ring Middlewareを適用したHandlerで動くことになります。

少し意味のあるMiddlewareを書いてみます。 例として、引数として受け取ったHandlerを実行する前にリクエストマップの値を出力して、Handlerを実行した後に結果を返す前にレスポンスマップを出力するMiddlewareを書いてみます。

(defn debug-middleware [handler]
  (fn [request]
    (clojure.pprint/pprint request) ; 前処理
    (let [res (handler request)]
      (clojure.pprint/pprint res)   ; 後処理
      res)))

何もしない no-mean-middleware と見比べてみると、 (handler request) と引数でもらったhandlerを使う前後に clojure.pprint/pprint しているだけなのがわかると思います。

これをこんな感じで適用してやると、リクエストマップの中身とレスポンスマップの中身を見ることができます。

(-> handler
    (no-mean-middleware)
    (debug-middleware))

ここまで、順を追えば簡単なことだと思いますが、実際のところこれがRing MIiddlewareのすべてです。

  • (handler request) をする前に何か処理をしたり、handlerの引数であるrequestをいじったりする(前処理)
  • (handler request) の結果を補足して、結果をいじったりする(後処理)

これだけです。

例えば、requestの中身をみて、URLごとにリクエストボディをいじるようなことをしたり、認証をかけたりといった具合です。

もちろん引数として渡されたhandlerを使わずに無視して他のことをすることもできます。 次のMiddlewareでは /hatena にリクエストが来たら、handlerを無視して https://www.hatena.ne.jp/ にリダイレクトします。

(defn hatena-middleware [handler]
  (fn [request]
    (if (= (:uri request) "/hatena")
      {:status 303
       :headers {"Location" "https://www.hatena.ne.jp/"}
       :body ""}
      (handler request))))

最後にMiddlewareで適用した前処理と後処理が実行される順番ですが、 あるMiddlewareに渡されるhandlerは、別のMiddlewareの前処理と後処理を含んだものであるので、 あるMiddlewareの前処理は先に適用されたMiddlewareの前に、逆に後処理は先に適用されたMiddlewareの後に適用されます。

文章で書くと複雑ですが、Threading Macroを利用すると、次のように視覚的にわかりやすく説明可能です。

(-> handler
    (hatena-middleware)  ; 前処理3番目、後処理1番目
    (debug-middleware)   ; 前処理2番目、後処理2番目
    (no-mean-middleware) ; 前処理1番目、後処理3番目
    )

Ring Middleware大全

ここからは、実際に使われるMiddlewareを解説していきます。

URLに応じて適用する

まず前処理・後処理として、具体的な役割をするMiddlewareの前に、 Middlewareそれ自体を条件に一致したときに特定したり・しなかったりするのを制御するものを紹介します。

正確にはこれはRing Middlewareではないですが、Middlewareと一緒に使うものなので先に紹介します。

これにはring.middleware.conditionalを使います。

if-url-starts-with, if-url-doesnt-start-with , if-url-matches , if-url-doesnt-match の4つのAPIがあります。

例えば次のようにすると、URLが /api から始まる時だけ先ほど書いた debug-middleware を適用することができます。

(require '[ring.middleware.conditional :as r.m.c])

(-> handler
    (r.m.c/if-url-starts-with ["/api"] debug-middleware))

リクエスト・レスポンスをロギングする

ring-loggerを使うと、リクエストの開始・終了や、リクエストボディ(debugレベル)をロギングすることができます。

(require '[ring.logger :as r.l])

(-> handler
    (r.l/wrap-with-logger))

[開発用]スタックトレースを出力する

ring.middleware.stacktrace/wrap-stacktraceを使うと、例外をキャッチして、stacktraceをHTMLで、もしくは標準出力に出してくれます。

(require '[ring.middleware.stacktrace ::as r.m.strace])

(-> handler
    (r.m.strace/wrap-stacktrace {:color? true}))

X-Forwarded-For ヘッダからリクエスト元IPアドレスを取得する

IPアドレス制限の機能を作るときなど、ユーザーのIPアドレスを取得したい時があります。

X-Forwarded-Forを使って、途中にProxyがある場合を考慮しつつ、ユーザーのIPアドレスを取得します。

ring.middleware.proxy-headers/wrap-forwarded-remote-addrを使います。

(require '[ring.middleware.proxy-headers :as r.m.proxy])

(-> handler
    (r.m.proxy/wrap-forwarded-remote-addr))

適用すると、リクエストマップの :remote-addr が更新されます。

X-Forwarded-Proto ヘッダからプロトコルを判別する

X-Forwarded-Proto を使って、例えばロードバランサーの背後のアプリでもプロトコルがHTTPなのかHTTPSなのか判別できます。

ring-sslを使います。

(require '[ring.middleware.ssl :as r.m.ssl])

(-> handler
    (r.m.ssl/wrap-forwarded-scheme))

適用すると、リクエストマップの :scheme:http または :https が入ります。

HTTPで来たアクセスをHTTPSにリダイレクトする

これもring-sslを使います。

(require '[ring.middleware.ssl :as r.m.ssl])

(-> handler
    (r.m.ssl/wrap-ssl-redirect))

これはリクエストマップの :scheme をみて判別するので、ロードバランサとhttpで通信している場合などは、 上述の wrap-forwarded-scheme と一緒に使うことが必要です。

HSTSに対応する

Strict-Transport-Securityに対応します。

これもring-sslを使います。

(require '[ring.middleware.ssl :as r.m.ssl])

(-> handler
    (r.m.ssl/wrap-hsts))

ただしこれはリバースプロキシとしてNginxなどを入れるとそこでできますので、 Clojureアプリ側でやるべきかどうかについては疑問ではあります。

X-Content-Type-Options をレスポンスにつける

X-Content-Type-Optionsをレスポンスにつけます。

ring.middleware.x-headers/wrap-content-type-optionsを使います。

(require '[ring.middleware.x-headers :as x])

(-> handler
    (x/wrap-content-type-options :nosniff))

ただしこれはリバースプロキシとしてNginxなどを入れるとそこでできますので、 Clojureアプリ側でやるべきかどうかについては疑問ではあります。

X-Frame-Options をレスポンスにつける

X-Frame-Optionsをレスポンスにつけます。

ring.middleware.x-headers/wrap-frame-optionsを使います。

(require '[ring.middleware.x-headers :as x])

(-> handler
    (x/wrap-frame-options :deny))

ただしこれはリバースプロキシとしてNginxなどを入れるとそこでできますので、 Clojureアプリ側でやるべきかどうかについては疑問ではあります。

X-XSS-Protection をレスポンスにつける

X-XSS-Protectionをレスポンスにつけます。

ring.middleware.x-headers/wrap-xss-protectionを使います。

(require '[ring.middleware.x-headers :as x])

(-> handler
    (x/wrap-xss-protection true :block))

ただしこれはリバースプロキシとしてNginxなどを入れるとそこでできますので、 Clojureアプリ側でやるべきかどうかについては疑問ではあります。

If-Modified-Since に対応する

If-Modified-Sinceに対応することで、 ファイルに更新がなければ、キャッシュを使うように指示できます。

ring.middleware.not-modified/wrap-not-modifiedを使います。

(require '[ring.middleware.not-modified :as r.m.nm])

(-> handler
    (r.m.nm/wrap-not-modified))

Content-Type がレスポンスにない場合につける

ring.middleware.default-charset/wrap-default-charsetを使います。

例えば、レスポンスマップの Content-Typetext/html が設定されている時に、 text/html; charset=utf-8 と変更してくれます。 文字化け防止ですね。

(-> handler
    (r.m.dc/wrap-default-charset "utf-8"))

URLの拡張子をみて Content-Type をつける

ring.middleware.content-type/wrap-content-typeを使います。

これはレスポンスマップに Content-Type が設定されていない時に、例えばリクエストマップの :uri/hoge.csv ならば text/csvContent-Type に設定してくれるというものです。

動的コンテンツを返す場合というより、後述するディレクトリ上のファイルやクラスパス上のファイルを直接返す時にセットで使うものですね。

(require '[ring.middleware.content-type :as r.m.ct])

(-> handler
    (r.m.ct/wrap-content-type))

ディレクトリ上のファイルをレスポンスする

ring.middleware.file/wrap-fileを使います。

リクエストされたパスに一致する、サーバーのディレクトリ上のファイルをそのまま返したい時に使います。

クラウド上の使い捨て前提のサーバーにデプロイする前提だと、そのサーバー上のディレクトリのファイルに依存することはあまりないでしょうから、 今はあまり使わないものではないでしょうか。

(require '[ring.middleware.file :as r.m.f])

(-> handler
    (r.m.f/wrap-file "/my/static/file/root/"))

クラスパス上のファイルをレスポンスする

ring.middleware.resource/wrap-resourceを使います。

こちらはよく使うと思います。 Clojureアプリケーションはビルドして単一のJARファイルにしてそれを java -jar で起動することが多いと思います。 その時にクラスパス上にCSSファイルやJSファイルなどを含めておいて配信するということをします。

クラスパス上のファイルパスは clojure.java.io/resource で参照することができますが、このMiddlewareはこれを使ってリクエストに一致するファイルが存在すればそれを返してくれます。 ちゃんと Last-Modified 付きです。

root-path を指定するので、見えて良いパス配下だけに限定することができます。

(require '[ring.middleware.resource :as r.m.r])

(-> handler
    (r.m.r/wrap-resource "public"))

相対パスのリダイレクトURLを絶対パスに変換する

ring.middleware.absolute-redirects/wrap-absolute-redirectsを使います。

(ring.util.response/redirect "/foo") とすると、

{:status 302
 :headers {"Location" "/foo"}
 :body ""}

このような相対パスのリダイレクトになりますが、これを http から始まる絶対パスに直してくれます。

(require '[ring.middleware.absolute-redirects :as r.m.ar])

(-> handler
    (r.m.ar/wrap-absolute-redirects))

Cookieを読み書きする

ring.middleware.cookies/wrap-cookiesを使います。

リクエストからCookieを読み :cookies に入れたり、レスポンスマップから :cookies を読み Set-Cookie ヘッダを設定してくれます。

実際のアプリケーションでは後述する wrap-session を使ってセッションを実現し、これ単体ではあまり使うケースはないと思います。

(require '[ring.middleware.cookies :as r.m.cookie])

(-> handler
    (r.m.cookie/wrap-cookies))

フォームからPOSTされたデータやクエリパラメータを読み :params に入れる

ring.middleware.params/wrap-paramsを使います。

次の2つをやってくれます。

  • Content-Typeapplication/x-www-form-urlencoded だった時に(FormタグでPOSTした時に)、bodyを読み込んで、 :form-params:params に入れてくれます
  • クエリパラメータがあれば、読み込んで :query-params:params に入れてくれます

なお、この時両方あれば :params はマージされたものになります。

(require '[ring.middleware.params :as r.m.params])

(-> handler
    (r.m.params/wrap-params))

multipart/form-data のリクエストボディを読み :params に入れる

ring.middleware.multipart-params/wrap-multipart-paramsを使います。

Content-Typemultipart/form-data だった時に、bodyを読み込んで :multipart-params:params に入れてくれます。 これらのキーがすでにある場合はマージされます。

bodyのファイルは、デフォルトでは一時ファイルとしてディレクトリ上におかれ、 :params は次のキーと値が入ります。

  • :filename
  • :content-type
  • :tempfile (java.io.Fileインスタンス)
  • :size
(require '[ring.middleware.multipart-params :as r.m.mparams])

(-> handler
    (r.m.mparams/wrap-multipart-params))

:params 内のネストしたキー文字列をネストしたマップにする

ring.middleware.nested-params/wrap-nested-paramsを使います。

これまで紹介してきた wrap-params もしくは wrap-multipart-params を使うと、フォームからPOSTされたデータ、もしくはクエリパラメータを :params に集めることができます。

その時にネストしたデータを表現したい時にこのMiddlewareを使うと、 フォームのnameもしくはクエリパラメータのkeyを "foo[bar]" のようにすることで、 :params の中身を {"foo" {"bar" "値"}} のように展開することができます。

できることの詳細は上のリンクを参照してください。

(require '[ring.middleware.nested-params :as r.m.nested])

(-> handler
    (r.m.nested/wrap-nested-params))

:params のキーをKeywordに変換する

ring.middleware.keyword-params/wrap-keyword-paramsを使います。

wrap-params wrap-multipart-params を使うことで :params フォームからPOSTされたデータ、もしくはクエリパラメータを :params に集めることができましたが、 :params のキーはまだStringのままで、Clojureで扱うにはKeywordであったほうが扱いやすいです。

このMiddlewareを適用すると、 :params のキーをKeywordに変えてくれます。 ここで、 :form-params:query-params などは変更されないので注意してください。

(require '[ring.middleware.keyword-params :as r.m.kw])

(-> handler
    (r.m.kw/wrap-keyword-params))

sessionに対応する

ring.middleware.session/wrap-sessionを使います。

これを適用するとCookieと後述するsession storeからsessionを読み込み、リクエストマップに :session キーとしてデータを追加してくれます。 また、レスポンスマップに :session を追加すると更新され、またその値がnilであれば削除されます。

どこにsessionデータを保存するかを制御するために、 :store というオプションを使います。 指定しなかった場合は、メモリ上に保存されますが、そのままでいいことはあまりないでしょう。

ここでは実践的な例として、Redisに保存する例を紹介します。Redisクライアントとしてcarmineを使います。

(require '[ring.middleware.session :as r.m.session]
         '[taoensso.carmine.ring :as carmine])

(let [redis-conn-opts {:pool {}
                       :spec {:host "localhsot"
                              :port "6379"
                              :password "foobar"
                              :timeout-ms 1000
                              :db 1}}
      session-store (carmine/carmine-store redis-conn-opts)]
  (-> handler
      (r.m.session/wrap-session {:store session-store})))

一度きりのsessionを使う

ring.middleware.flash/wrap-flashを使います。

レスポンスマップに :flash というキーがあれば、それを次のリクエストの時だけ、リクエストマップに :flash というキーでデータを渡してくれます。 これはsessionに依存しているので、上記の wrap-session と共に使うことが前提です。

(require '[ring.middleware.flash :as r.m.flash])

(-> handler
    (r.m.flash/wrap-flash))

CSRFへの対策をする

ring-anti-forgeryを使います。

このMiddlewareは使うだけならば簡単なのですが、このMiddlewareを入れるとGET/HEAD/OPTIONS以外のリクエスト全てで、CSRFトークンがないとエラーになるようになります。

そのため、 フォームからPOSTするときは、 ring.util.anti-forgery/anti-forgery-field を含め、 XMLHTTPRequestする時は、 ring.middleware.anti-forgery/*anti-forgery-token* をHTMLに含めて返し、 JS上でそれを読んだ上で、 ヘッダに x-csrf-token として設定する必要があります。

またCSRFトークンエラーが起こった時の処理として :error-handler にhandler関数を渡すことができます。 実践的にはこれも必要でしょう。

こちらもsessionに依存しているので、上記の wrap-session と共に使うことが前提です。

(require '[ring.middleware.anti-forgery :as r.m.af])

(-> handler
    (r.m.af/wrap-anti-forgery {:error-handler (fn [_] {:status 403
                                                       :headers {}
                                                       :body "Invalid csrf token"})}))

閑話休題 - Ring-Defaultsで一度に設定する

ここまで多くのMiddlewareを設定してきましたが、1つ1つrequireしていくのは大変です。

そこで ring-defaultsを使って、 wrap-forwarded-remote-addr から wrap-anti-forgery までを一括で適用します。

ring-defaultsにはapi-defaults、site-defaults、secure-api-defaults、secure-site-defaultsの4つのデフォルト設定が用意されていますが、 オススメなのはこれを使わずに自分で以下のようにマップを作って直接渡すことです。 なぜオススメなのかというと、結局細かいことをしようとした時にデフォルト設定をいじる必要があり、その時にassocなりで操作したのを渡していると、 結局何がどうなっているのか見えづらいためです。

(require '[ring.middleware.defaults :as defaults]
         '[taoensso.carmine.ring :as carmine])

(let [redis-conn-opts {:pool {}
                       :spec {:host "localhsot"
                              :port "6379"
                              :password "foobar"
                              :timeout-ms 1000
                              :db 1}}
      session-store (carmine/carmine-store redis-conn-opts)
      default-config {:params {:urlencoded true
                               :multipart true
                               :nested true
                               :keywordize true}
                      :cookies false
                      :session {:flash true
                                :cookie-attrs {:http-only true
                                               :same-site :strict
                                               :secure true}
                                :store session-store}
                      :security {:anti-forgery {:error-handler (fn [_] {:status 403
                                                                        :headers {}
                                                                        :body "Invalid csrf token"})}
                                 :hsts false
                                 :ssl-redirect false}
                      :static {:resources "public"}
                      :responses {:not-modified-responses true
                                  :absolute-redirects true
                                  :content-types true
                                  :default-charset "utf-8"}
                      :proxy true}]
  (-> handler
      (defaults/wrap-defaults default-config)))

Basic認証に対応する

ring-basic-authenticationを使うのが簡単です。

特定のURLだけにかけたい場合は、前述の if-url-starts-with などと併用しましょう。

(require '[ring.middleware.basic-authentication :as r.m.basic])

(def basic-username "foo")
(def basic-pass "pass")

(-> handler
    (r.m.basic/wrap-basic-authentication (fn [username pass]
                                           (and (= username basic-username)
                                                (= pass basic-pass)))))

CORSに対応する

Ring CORSを使います。

このライブラリの良いところは、 Access-Control-Allow-Origin に正規表現を使えるところです。

(require '[ring.middleware.cors :as r.m.cors])

(-> handler
    (r.m.cors/wrap-cors :access-control-allow-origin [#"https://.*example.com"]
                        :access-control-allow-headers #{:accept :content-type :authorization}
                        :access-control-allow-methods #{:get :put :post :delete}))

認証・認可を行う

buddy-authを使います。

buddy-authはbasic, session, token, JWT, JWEなど各種の認証に対応していますが、 詳細はドキュメントに譲るとして、 ここではセッション認証のみの実例を以下に示します。

ここではコード上のコメントでやるべきことを解説します。

(require '[ring.util.http-response :as res]
         '[buddy.auth.accessrules :as baa]
         '[buddy.auth.backends :as bab]
         '[buddy.auth.middleware :as bam])

(defn login-post
  "ログインフォームからのポストを受けるハンドラー関数だと思ってください"
  [{:keys [params session]}]
  (if-let [account (login params)]
    (-> (res/ok)
        ;; セッションに、identityというキーでログイン情報を入れます
        ;; buddy-authとは関係ありませんが、 :recreate をtrueとしてセッションを再作成しています
        (assoc :session (vary-meta (assoc session :identity account)
                                   assoc :recreate true)))
    (res/bad-request "login error")))

(defn- any-access [_]
  (baa/success))

(defn- account-access
  [{:keys [identity]}]
  (if identity
    (baa/success)
    (baa/error)))

(def ^:private rules ;; ruleは先頭から順に評価される
  [
   ;; 例えば、ログイン画面誰でもアクセス可能にする
   {:uris ["/login"]
    :handler any-access}
   ;; 例えば、APIはクライアント側で認証が切れていることを検知したら401を返す
   {:pattern #"\A/api.*\z"
    :handler account-access
    :on-error (fn [_ _] (res/unauthorized))}
   ;; 例えば、その他の全てのパスはsession認証している必要があることにし、
   ;; 認証されていなければログイン画面に飛ばす
   {:pattern #"\A/.*\z"
    :handler account-access
    :on-error (fn [_ _] (res/see-other "/login"))}])

(defn- session-authfn
  "上のlogin-postでセッションにidentityを入れると、
  次のアクセス時にidentityがセッションに入ってくる。
  identityがセッションにある場合にこの関数が呼ばれ、その中身が正しいものか
  検証した上で、正しければ、identityを返す。
  ここでidentityを改変することも可能。"
  [identity]
  ;; 例えば、パスワードは変更されていないか?などの検証をここでする
  identity)

(let [session-backend (bab/session {:authfn session-authfn})]
  (-> handler
      ;; 認可
      ;; 上のルールに指定した挙動をさせる
      (baa/wrap-access-rules {:rules rules})
      ;; 認証
      ;; backendは複数指定可能
      ;; 例えば、セッションかtokenでの認証を許可するなどする
      ;; if-url-starts-with などと併用すれば、URLごとに処理を分けることもできる
      (bam/wrap-authentication session-backend)))

APIのリクエストに対応する

Muuntajaを使うのがオススメです。

リクエストマップの Content-Type をみて、bodyをパースし、 :params に入れてくれます。 またレスポンスマップも同様に適切にbodyを変換してくれます。

ただしこのMiddlewareを通す前にレスポンスマップに Content-Type を設定してしまうと、 動作しないことに注意が必要です。

(require '[muuntaja.middleware :as muu]
         '[ring.util.http-response :as res])

(defn hoge-api [_]
  ;; handlerではこれだけで返すようにする
  (res/ok {:data "foobar"}))

(-> handler
    ;; こっちは :body-params を :params にマージしてくれるもの
    (muu/wrap-params)
    ;; こっちがパースして、 :body-param に設定してくれるもの
    (muu/wrap-format))

ルーティングしやすいようにURL末尾の / を削除する

これはライブラリを使わず自分で書きます。

リクエストマップの :uri/foo/ のように末尾に / に入っていることで、ルーティングがうまく機能しないケースがあります。

そこで次のようなMiddlewareを使うことで、この問題を吸収します。

(defn trailing-slash [path]
  (if (and (string? path)
           (not= path "/")
           (clojure.string/ends-with? path "/"))
    (subs path 0 (dec (count path)))
    path))

(defn wrap-trailing-slash
  "URLから末尾の'/'を削除"
  [handler]
  (fn [req]
    (-> req
        (update :uri r/trailing-slash)
        (update :path-info r/trailing-slash)
        handler)))

(-> handler
    (wrap-trailing-slash))

URLとリクエストメソッドごとに異なるhandlerを呼ぶ

最後に、ルーティングとそれぞれのhandlerとを結合させる方法を紹介して、本記事を終わりとします。

ここでは、ルーティングライブラリとしてbidiを使い、Multimethodと組み合わせた方法を紹介します。 これは実際に弊社製品で使っている方法でもあります。

(require '[ring.util.http-response :as res])

(def routes
  ["/" [["" :route/home]
        ["foo" :route/foo]
        [true :route/not-found]]])
;; (bidi/match-route routes "/foo")
;; => {:handler :route/foo}

(defmulti handler
  ;; リクエストマップの :handler キーの値でディスパッチ
  (fn [request] (:handler request)))

(defmethod handler :route/home
  (-> "Hello world"                                         ; 本来はHTMLをここで作るようにする
      (res/ok)
      (res/content-type "text/html")))

(defmethod handler :route/foo
  (-> "foo"
      (res/ok)
      (res/content-type "text/html")))

(defmethod handler :route/not-found
  [_]
  (res/content-type (res/not-found) "text/html"))

(extend-protocol bidi.ring/Ring
  ;; 元々のbidiはclojure.lang.Fnかclojure.lang.Varにしか対応していないので、
  ;; keywordに対応を広げておく
  clojure.lang.Keyword
  (request [kw request _]
   ;; kwには :route/foo のようなkeywordがくる
   ;; マッチしたhandlerをリクエストマップに入れておくことで、
   ;; マルチメソッドのディスパッチに使う
   (handler (assoc request :handler kw))))

(-> (bidi.ring/make-handler routes)
    ;; 以下にMiddlewareが続く
    )

--- 2020/07/29追加 ---

Content-Security-Policy レスポンスヘッダを付与する

ring-middleware-cspを使います。

Clojureのmapで定義したポリシーを適切にフォーマットして、レスポンスヘッダのContent-Security-Policyに設定します。

:report-only?オプションを使えば、Content-Security-Policy-Report-Onlyヘッダを使うこともできます。 他にも、固定のポリシーではなく、リクエストに応じた動的なポリシーを使ったり、nonceを簡単に設定できます。

実はこのmiddlewareは弊社が作成したものです。 使いづらい点や機能の追加などがあれば、issueやPRもお待ちしています。

(require '[ring-middleware-csp.core :refer [wrap-csp]])

(def policy {:default-src :none
             :script-src [:self]
             :style-src ["https://example.com" :unsafe-inline]
             :report-uri "/csp-report"})

(-> handler
    (wrap-csp {:policy policy}))
; Content-Security-Policyに "default-src 'none';script-src 'self';style-src https://example.com 'unsafe-inline';report-uri /csp-report"が設定される

更新履歴

おわりに

概ね代表的なRing Middlewareについては紹介したように思いますが、また別な有用なものがあれば追記していこうと思います。

次回は状態管理を入れて、アプリケーション全体の構成を作る方法について紹介しようと思います。 またその時には実用的なサンプルアプリも紹介できればと思います。

それでは良いお年を!


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

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

Zipperでコードフォーマッターを実装する (greglook/cljstyle に学ぶ)

北川です。

Clojure 始めました。 Clojure のお勉強をするにあたり何か良い題材はないかと探していたところ greglook/cljstyle というツールが目につきました。 以前からフォーマッターや linter の実装に興味があったということもあり、ちょうど良いのでこれを題材にお勉強を始めていきます。

コンテキスト不明なものをいきなり読んでも眠くなってしまいそうなので greglook/cljstyle のソースコードをそのまま抜粋するなどはしません。 しかし、この記事を読み終える頃には自分好みのコードフォーマッターを再実装できそうな気持ちになれます。*1

実際にcljstyleを使ってフォーマットをかけていく方法については、こちらの記事をご覧ください。

tech.toyokumo.co.jp

フォーマット処理の流れ

例えば cljstyle check コマンドは以下の通りです。

  1. Clojure コードの文字列を取得
  2. 文字列をパースしてツリーを取得
  3. ツリーをトラバースしてフォーマットルールに反した箇所を探す
  4. 変更する
  5. オリジナルソースと変更後ソースの diff をとる
  6. 結果を整形して表示する

1, 5, 6 についてはやるだけなので 2, 3, 4, について見ていきます。

Zipper

本題に入る前に Zipper についてほんのり触れておきます。Zipper とは1996年に Gérard Huet という人が発明したデータ構造です。 Clojure のような immutable なデータ構造を変更する際 Zipper を適用すると比較的簡単な操作で効率的に再構築できるというものです。Zipper の特徴は現在の注目点(loc)と、そこに至るまでのパスを保持している点です。あまり Zipper について思いを馳せると長くなってしまうので、今回は便利なAPIだなあと思うことにします。さっそく使い方を見ていきます。

(require [clojure.zip :as zip])

(-> (zip/vector-zip [1 2 [3 4]])
    (zip/down))

;;=> [1 {:l [], :pnodes [[1 2 [3 4]]], :ppath nil, :r (2 [3 4])}]

vector-zip を使うと vector をトラバースするための Zipper が得られます。zip/down でルートからひとつ下に降り、注目点が 1, right-node が [2 [3 4]] という状態になりました。

(require [clojure.zip :as zip])

(-> (zip/vector-zip [1 2 [3 4]])
    (zip/down)
    (zip/right))

;;=> [2 {:l [1], :pnodes [[1 2 [3 4]]], :ppath nil, :r ([3 4])}]

さらに right-node へ進むと、注目点が 2, right-node が [3 4]、left-node が [1] という状態になりました。

(require [clojure.zip :as zip])

(-> (zip/vector-zip [1 2 [3 4]])
    (zip/down)
    (zip/right)
    (zip/edit inc)
    (zip/root))

;;=> [1 3 [3 4]]

注目点の値を1増やし zip/root でデータを再構築すると変更後の vector を取得することができました。このように Zipper はデータを歩き回り変更したい時に便利ですね。

rewrite-clj で文字列をパース

greglook/cljstyle では文字列をパースしてツリーを取得する工程で、xsc/rewrite-clj が使われています。rewrite-clj の返す Zipper は clojure.zip の上に実装されているので rewrite-clj の提供するAPIはもちろん使えますし先ほどの zip/right のような clojure.zip が提供する操作も問題なく使うことができます。動きを見ていきます。

(require [rewrite-clj.zip :as z])

(z/of-string "(defn add [a b] (+ a b)) (add 1 2)")

;;=> [<list: (defn add [a b] (+ a b)) >
;;  {:l [], :pnodes [<forms: (defn add [a b] (+ a b)) (add 1 2) >], :ppath nil,
;;  :r (<whitespace: " " > <list: (add 1 2) >)}]

z/of-string は文字列から Clojure コードツリーの Zipper を生成します。現在の注目点が (defn add [a b] (+ a b)) フォームとなり、right-node が残りの (add 1 2) という部分です。 ここでうれしいのが、ホワイトスペース等のフォームの評価時には不要なトークンを保持しているという点です。これはコードフォーマッターを実装する時にはとても都合が良さそうです。

実際にフォーマットしてみる - surrounding-whitespace の除去

greglook/cljstyle でもフォーマットルールとして定義されている surrounding-whitespace の除去をやってみます。

(   * a 3 ) このようなフォームを (* a 3) このようにフォーマットします。

(require
  [clojure.zip :as zip]
  [rewrite-clj.zip :as z])

(defn surrounding-whitespace?
  [zloc]
  (and (z/whitespace? zloc)
       (or (nil? (zip/left zloc))
           (nil? (z/skip zip/right z/whitespace? zloc)))))

(defn remove-surrounding-whitespace
  [form]
  (loop [zloc (z/of-string form)]
    (if-let [zloc (z/find-next zloc zip/next surrounding-whitespace?)]
      (recur (zip/remove zloc))
      zloc)))

remove-surrounding-whitespace では最初に (z/of-string form) で受け取った Clojure コードをトラバースするための Zipper を作ります。

(z/find-next zloc zip/next surrounding-whitespace?) では、述語 surrounding-whitespace? にマッチするノードを探します。

zip/nextz/find-next が使用する次の注目点への移動関数です。rewrite-clj.zip が提供する z/next はホワイトスペースやコメントをスキップするため普通に Zipper を歩く分には便利なのですが、コードフォーマッターとしてはスキップして欲しくないです。そこで clojure.zip が提供する zip/next を使います。zip/next はホワイトスペース等を無視せず Zipper を深さ優先的に探索するので今回はこれが使えます。

surrounding-whitespace? は注目点がホワイトスペースなノードであることと、フォーム左端または右端であることを調べています。

(z/skip zip/right z/whitespace? zloc) では、Zipper 全体ではなくトップレベルの1フォーム内をホワイトスペース等は無視せず探索したいため z/skipzip/right を使用していることがポイントです。

(require
  [clojure.zip :as zip]
  [rewrite-clj.zip :as z])

;; ...

(z/root-string (remove-surrounding-whitespace "(    * a 3  )"))
;;=> "(* a 3)"

(z/root-string (remove-surrounding-whitespace "(  + 2 3 (    * 4 5    )  )   (   + d 3    )"))
;;=> "(+ 2 3 (* 4 5))   (+ d 3)"

動いているように見えます。

一般化する

先ほどの remove-surrounding-whitespace から、フォーマットルールに反しているか調べる述語と、ルール違反を検知したら何をするか、を分離すると一般化することができます。

(require
  [clojure.zip :as zip]
  [rewrite-clj.zip :as z])

(defn edit-all
  [form p? f]
  (loop [zloc (z/of-string form)]
    (if-let [zloc (z/find-next zloc zip/next p?)]
      (recur (f zloc))
      zloc)))

さらにコードフォーマッター用の設定ファイル等からマップを生成し、設定が有効ならば何をするかを定義することができます。

(require
  [clojure.zip :as zip]
  [rewrite-clj.zip :as z])

(defn remove-surrounding-whitespace
  "Transform this form by removing any surrounding whitespace nodes."
  [form]
  (z/root-string (edit-all form surrounding-whitespace? zip/remove)))

(defn reformat-form
  "Transform this form by applying formatting rules to it."
  [form config]
  (cond-> form
    ;; config は
    ;; { :remove-surrounding-whitespace? true, :another-rule? false, ... }
    ;; のようなマップ
    (:remove-surrounding-whitespace? config true)
    (remove-surrounding-whitespace)
    ;; ...
    ))

あとは、フォーマットルールに反しているか調べる述語と、ルール違反を検知したら何をするか、をひとつひとつ実装していけばコードフォーマッターが完成しそうですね。

ここまでくると greglook/cljstyle のソースコードにかなり近くなってきました。実際にはもっと細やかな気配りのもと書かれていますが、要旨は押さえられていると思います。

(上記の実装だと設定項目の数だけソースコード全体をパースして走査することになるのが少し気になりますね。)

最後に

自分でもコードフォーマッターが実装できそうな気持ちになれました。トークン列からソースコード上の位置などを取得できれば linter も同じように実装できそうです。

また、rewrite-clj といったツールを作るためのツールの存在は偉大ですね。ぼくも rewrite-clj を使って何かやりたいなあと思えてきました。


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

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

*1:気持ちになれます

会社の金で買った HHKB Professional HYBRID Type-S (2台) の打鍵感はいかほどか

こんにちは。このブログでまだ技術的なことを1つも書いていない開発本部の飯塚です。

自分の好きなキーボードを買ってくれる会社も最近では珍しくなくなってきたかと思います。 ご多分に漏れず弊社トヨクモでもPCとキーボードは自分で好きなものを選び買ってもらうことができます。

しかし「左右の手で1台ずつ使いたいから」と言うバカみたいな理由で 2 台買ってくれる会社はどれだけあるでしょうか? それもつい最近発売された HHKB Professional HYBRID Type-S を!

『会社の金で買ってもらった』 HYBRID Type-S 2 台 (上の Pro2 Type-S 2台は今まで使っていた個人的に買ったもの)

経緯

私は元々使っていた HHKB Professional2 Type-S があったので、キーボードに関しては買ってもらっていなかったのですが、 Type-S 好きとして HYBRID Type-S は見逃せず買ってもらうことは確定事項でした。

ただ懸念が1つあり、私は普段 HHKB を 2 台並べて左右の手で1台ずつ使う「なんちゃって分離HHKBスタイル」を採用しているので、 同じことをやろうとすると、この高級キーボードを2つ買う必要があります。 1台くらい自腹でいいかと思いつつ、ダメ元で2台買ってもらえたりするんですか?と聞いてみたところ、

「2台買えるルールがあるわけじゃないけれど、飯塚さんこだわり強いから特例で」

おぉ、普段から2台体制でこだわってますアピールしていて良かった。。

到着

産まれたての HYBRID Type-S

買ってもらったのは英語配列の色は墨です。 配列はもう日本語配列を使えない体になってしまっているので英字一択でしたが、色は Type-S として今回初めて墨が選べるようになったので折角なので墨にしました。

刻印はキーマップの変更もあるので無刻印も少し考えましたが、個人的に何となく好みじゃないので刻印ありです。

左右に仲良くならぶ双子の HYBRID Type-S

諸事情で今は有線接続していますが、無線接続でも2台体制は問題ないそうです。 打鍵音は同じ Type-S なので Pro2 Type-S 同様に「トコトコ」と軽い音ですが、心なしか HYBRID の方がより軽い音な気がします。 しかし打鍵感がないわけではなく、しっかりと打てている感はありとても気持ちいいです。

キーマップ変更

さて今回の目玉機能の1つであるキーマップの変更ですが、結論から言うと今はしていません。

私は普段 Vim 上で Clojure を書いているので、以下のようなキーマップを Vim 上で行っています。

  • コロンとセミコロンの入れ替え
  • 数字と記号の入れ替え(e.g. "9" と "(" や "5" と "%" の入れ替え)

しかし現状の Happy Hacking Keyboard キーマップ変更ツールではどうもこれらの入れ替えができないようです。 今後の更新で対応可能な範囲かはわからないですが、ここまで変更できると個人的にはかなり嬉しいです。

キーの場所は変えられても同じキーで入力内容が入れ替えられない

最後に

会社の金で買った HHKB Professional HYBRID Type-S (2台) の打鍵感は最高です!

どうも一部のモデルがすでに在庫切れになっているようですが、Type-S を使っていない人はもちろん、使っている人も触ってみるのをおすすめします。


トヨクモでは一緒に働いてくれるエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。