Toyokumo Tech Blog

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

背景色と文字色でどの色の組み合わせが見やすいか?Webデザイナもプログラマも迷わなくなる方法

北川です。

先日、任意の色の背景がある時、テキストの文字色を黒か白どちらを使うのが適当か?という問題に当たりました。最終的にまあまあ満足のいく結果が得られたので記事にしました。

背景色と文字色の問題

人間が文字色を決めた

これは色々な背景色について黒か白の文字色の選択を筆者の主観に基づき決めた画像です。社内でフィードバックを求めたところ「黄緑や水色が見にくい」と不評でした。

このように当人としては見やすいと思っても、他の人から見やすいとは限りません。

またよくある問題として、一律黒文字テキストでスタイリングしていたがボタンの塗りつぶしが濃い色のときに見辛くなってしまった、ということもあると思います。

文字色が黒か白かに限らずそれ以外の任意の文字色でも同様な一般的な問題かと思います。

毎回どうすれば良いか判断するのも手間がかかって仕方がありません。何か良い解決方法はないものでしょうか?

WCAG

さて、WCAGというものがあります。WCAG(Web Content Accessibility Guidelines)は、ウェブコンテンツのアクセシビリティを向上させるための国際的な標準です。

今回の問題も文字の見やすさといったアクセシビリティに関連した話なので、ここにヒントがありそうです。

文字色決定戦略を決める

Web Content Accessibility Guidelines (WCAG) 2.2

The visual presentation of text and images of text has a contrast ratio of at least 4.5:1, except for the following

Contrast (Minimum) の項に関連しそうなことが書いてありました。contrast ratio というものが少なくとも 4.5:1 であるべきだと。なるほどまったく分かりません。

Contrast (Enhanced) の項には、contrast ratio は少なくとも 7:1 であるべきともあります。どうも contrast ratio は高ければ高いほど良さそうです。

次に、contrast ratio とは何かが気になります。

(L1 + 0.05) / (L2 + 0.05), where

L1 is the relative luminance of the lighter of the colors, and

L2 is the relative luminance of the darker of the colors.

と、contrast ratio の項にありました。どうやら contrast ratio は relative luminance という変数に依存した式で表せるようです。

さらに掻い摘んだ説明をChatGPTに聞いてみると「コントラスト比は、テキストや図形などの要素とその背景の間の明るさの差を示します。これは、テキストを読みやすくするために必要な要素である」と教えてくれました。コントラストと文字色との話がつながりましたね。この比が 4.5:1 または 7:1 より高ければクッキリハッキリ見えやすいということです。

次に、relative luminance とは何かが気になります。これが分かればついに文字色決定戦略を決められる予感がします!

the relative brightness of any point in a colorspace, normalized to 0 for darkest black and 1 for lightest white

For the sRGB colorspace, the relative luminance of a color is defined as L = 0.2126 * R + 0.7152 * G + 0.0722 * B where R, G and B are defined as:

と、relative luminance の項にありました。さっきの式よりも複雑ですが、知らない変数がありません!RGB値に依存した値が relative luminance のようです。

さらに掻い摘んだ説明をChatGPTに聞いてみると「相対輝度(Relative Luminance)は、色の明るさを表す指標の一つです。色の相対輝度は、その色がどれだけ明るいかを示し、通常、0から1までの範囲で表されます。0は完全な黒を示し、1は完全な白を示します。」と教えてくれました。要するに色の明るさのことでした。

「ある背景色について、文字色1と文字色2のどちらがよりアクセシビリティの観点で見やすいか?」

これまでの要素を踏まえると以下のように言い換えられそうです。

「色1について、色2と色3のどちらがよりコントラスト比(contrast ratio)が高いか?」

ここまでくると、このお題を解決する戦略が決められそうです。その戦略はこうです。

  • step1: 色1,2,3 についてそれぞれのRGB値を計算する
  • step2: 色1,2,3 についてそれぞれの相対輝度(relative Luminance)を step1 で求めたRGB値を使って計算する
  • step3: 色1,2のコントラスト比と、色1,3のコントラスト比(contrast ratio)を step2 で求めた相対輝度を使って計算する
  • step4: よりコントラスト比が大きい方がアクセシビリティの観点で見やすいので採用する

実装

// 色1について、色2と色3のどちらとのコントラスト比が高いかを返す
const selectHigherContrastRatio = (c1: string, c2: string, c3: string) => {
  // step1: それぞれのRGB値を計算する
  const [r1, g1, b1] = getRGB(c1);
  const [r2, g2, b2] = getRGB(c2);
  const [r3, g3, b3] = getRGB(c3);

  // step2: それぞれの相対輝度を計算する
  const L1 = getRelativeLuminance(r1, g1, b1);
  const L2 = getRelativeLuminance(r2, g2, b2);
  const L3 = getRelativeLuminance(r3, g3, b3);

  // step3: c1とc2とのコントラスト比とc1とc3とのコントラスト比を計算する
  const contrast1 = getContrastRatio(L1, L2);
  const contrast2 = getContrastRatio(L1, L3);

  // step4: どちらとのコントラスト比が高いか。高い方を返す。
  return contrast1 > contrast2 ? c2 : c3;
};

実装の細部は置いておくとして、戦略を直裁に書き下すとこのようになります。c1, c2, c3 は #ffffff といったカラーコードの形状の文字列を想定しています。

次は getRGB です。

const getRGB = (color: string) => {
  // color は '#ffffff' の形式
  const c = color.substring(1, 7);
  const r = parseInt(c.substring(0, 2), 16);
  const g = parseInt(c.substring(2, 4), 16);
  const b = parseInt(c.substring(4, 6), 16);
  return [r, g, b];
};

あまり説明することがないですね。次は getRelativeLuminance です。

// https://www.w3.org/TR/WCAG22/#dfn-relative-luminance
const getRelativeLuminance = (R8bit: number, G8bit: number, B8bit: number) => {
  const RsRGB = R8bit / 255;
  const GsRGB = G8bit / 255;
  const BsRGB = B8bit / 255;

  const R = RsRGB <= 0.03928 ? RsRGB / 12.92 : ((RsRGB + 0.055) / 1.055) ** 2.4;
  const G = GsRGB <= 0.03928 ? GsRGB / 12.92 : ((GsRGB + 0.055) / 1.055) ** 2.4;
  const B = BsRGB <= 0.03928 ? BsRGB / 12.92 : ((BsRGB + 0.055) / 1.055) ** 2.4;

  return 0.2126 * R + 0.7152 * G + 0.0722 * B;
};

これは relative luminance について調べる過程で実装例も一緒に見つけてしまったのでコピーぺしました。参考にしたコードもリンクさせていただきます。https://gist.github.com/jfsiii/5641126

最後に getContrastRatio です。

// https://www.w3.org/TR/WCAG22/#dfn-contrast-ratio
// コントラスト比の公式
// (L1 + 0.05) / (L2 + 0.05)
// L1: lighter color の relative luminance
// L2: darker color の relative luminance

const contrastRatioW3C = (L1: number, L2: number) =>
  L1 > L2 ? (L1 + 0.05) / (L2 + 0.05) : (L2 + 0.05) / (L1 + 0.05);

getContrastRatio では明るい色をL1に代入し暗い色をL2に代入しなければならないので、最初にふたつの相対輝度(色の明るさ)を比較します。そして明るい方をL1として、暗い方をL2として計算します。

これですべての関数を実装し終え selectHigherContrastRatio が完成しました!

冒頭の画像を思い出してください。色々な背景色について黒か白の文字色付けていく操作を完成した selectHigherContrastRatio を使ってもう一度やってみます。

selectHigherContrastRatio('いろいろな背景色', '#ffffff', '#000000');

WCAGの基準に沿って決めた

うん!なんだか良さそうです!特に不評だった黄緑と水色の文字色は黒になり、やはりアクセシビリティの観点で良くなかったと判明しました!

最後に注意点として、selectHigherContrastRatio によって選ばれた色の組み合わせが必ずしもアクセシビリティの観点で良いというわけではないということです。

そういうものが欲しい場合は、改善として contrast ratio が 4.5:1 か 7:1 を越えるよう検証するコードをどこかに入れると良さそうですね。

おわり

WCAG に沿った決定をした結果、ユーザー体験が向上し、デザイナと実装者の両方の頭を悩ませる負担を軽くすることができました。

よかったですね。


トヨクモ株式会社ではUXに真摯に向き合いたいデザイナ、Clojureを書きたいエンジニア、PHP/Reactを書きたいエンジニア、技術が好きなエンジニアを募集しております。

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

4月のTokyo Clojure会を開催しました!

自己紹介

こんにちは!アーロンと申します。4月1日にトヨクモのエンジニアとして入社させていただいて主にClojureについてブログを書いてる人です。アメリカ人で純粋な日本語話者ではないのでよろしくお願いします! Github - aburd (Aaron B) · GitHub

概要

4月11日にトヨクモのオフィスで4月のTokyo Clojure会を開催させていただきました!

元々の開催者はPaul Chunという方なんですが、開催することはできなくなりました。このグループで僕はClojure勉強できたので、無くなったら残念に思ってグループの管理者の責任を渡してもらいました。ちょうど僕がトヨクモに入社したので、Clojure使ってる会社として会場を貸すことになりました!

今月のテーマはHTMXでした! HTMXの紹介

内容

1. 僕はHypermediaとSPAのアーキテクチャーの違いとHypermedia中心のアーキテクチャーがどうやってClojure・HTMXができるのがというLTをさせていただきました

docs.google.com

2. HTMXとReititとHiccupに基づいたフレームワークsimpleui.ioの作成者Matthew MolloyさんからのSimpleUIの紹介をしていただきました

Matthew MalloyはSimpleUIで作ったゲームについて話してくれた

Matthew MalloyはSimpleUIで作ったゲームについて話してくれた

Matthew MalloyはSimpleUIを紹介してくれた

3. Pizzaを食べながらLTのこととWeb開発にどういう影響がありそうなのかというような会話できました

円になってピザを食べながらHTMXとWebの次の進化はどうなるのかを議論した

僕からの感想・学んだこと

やっぱり現在ウェブアプリを作るなら、SPAフロントエンドJSONを吐き出すバックエンドが通念になってるのですが、何を作るかによってそこまでやる必要がありません。むしろある条件によって大変なことにあう可能性があり、気をつけなければならないです。主にソフトチームはFacebook・Googleの規模ではないし、同じ規模の問題を解決してないかもしれないので、多分アーキテクチャーを真似する必要がありません。HTMXを使えば、コードベース行数がかなり減るしデプロイすることも減るだろうしブラウザーはすでに入ってる機能が利用できるし、何を作ってるかによってかなり複雑性が減る可能性があります。

SPA・JSONバックエンドのアーキテクチャーに向いたアプリ

  • 複雑なUI (例えばWebのスプシーアプリ、UIに更新が多く)
  • クライアントが多く、HTMLよりもJSONがパース・シリアライズことでバックエンドCPUを節約する必要がある場合 (これでも大きい規模でしか効果でない臭さがあり)
  • オフラインでも使えるようなアプリ

HTMXに向いたアプリ

  • 大体の「ビジネス用のアプリ」
  • UIにユーザー対話数は中級レベルのアプリ
  • クライアントはダウンロード速度が遅くても電波が悪くても使えることが必要 (HTMXのJSはただ14KB)

コロナ時代から対面のイベントがなくて、みんな久しぶりに会えたし、新しい顔も見えたし、楽しかったです 毎月・2っヶ月に1回としてのイベントをしていこうと思っておりまして、東京にいるならぜひ来てください!

Clojureを静的型付け言語にする

こんにちは、開発本部の松尾です。

Clojureは標準ライブラリに使いやすい道具が充実していて、可読性の高い抽象的なコードが書きやすく、Javaとの相互運用のお陰で使える資産も多いため実用的で、良い言語だと思っています。

しかし、おこがましくも1点だけ改善出来るところがあると思っています。それは、「静的型解析が出来ない(弱い)」という点です。

Pythonの型ヒント、Ruby3の静的型チェッカーの例に見られるように、静的型解析によるメリットは、パフォーマンスという観点を抜いても、バグを減らして開発効率を上げる観点から、もはや見過ごすことが出来ないのが時代の潮流だと感じています。特に中規模〜大規模の業務でのコードになってくると、正確さやドキュメント製の観点から重要度が増してくるかと思います。

ということで、静的型解析を出来るようにしていきたいと思いますが、なぜやる必要があるのか。既に他の手段は無いかについて次の項でお話します。

本題を見たい方は読み飛ばしてください。

なぜやる必要があるのか、代替手段の認識と反省

私が認識している中で、Clojureに型という概念を導入する方法は4つあります。

1. メタデータのタグ

^string, ^bytes などのように、シンボルの前につけているやつです。 しかし、Javaとの相互運用の際に最適化に使われるという目的が大きく、バグをへらす為の静的解析に使うにはあまりにも表現力が低いです。例えば、ある形を持ったマップの配列、といったClojureのオブジェクトの型を表現することが出来ません。

2. schema.core

これはトヨクモでも採用しているライブラリです。プリミティブな型やマップの型はもちろん、それらの配列や、Javaの型も扱うことが出来るので表現力は十分です。解析しきれない時は Anyを指定するか、単純に型を付けないかを選べば良く、書いた分だけ見返りが得られて、使っていない部分の開発を妨げることもありません。とても現実的だと思います。しかし、型があっているかどうかは実行時にしか分かりません。主に関数の入力と出力の際に検証をしているので、出せるエラーメッセージには限界があります。また、コードにおける原因と結果が遠いときは問題箇所を見つけづらいという問題意識があります。 また、全てのコードパスを確認するためには、多くのテストを書く必要があります。

3. core.typed Clojureのコードを静的解析することが出来ます。

しかし、以下のリンク先では、CircleCIが実際に導入してから2年後、実用を通して分かった問題点が挙げられています。これは重要参考文献だと思います。 circleci.com

簡単にまとめると以下の通りです。

  1. 解析が遅い
  2. 型を解析しきれないことがあり、その際に型チェックを無視するか、複雑な型アノテーションをつけるかを選ばなくてはならない
  3. サードパーティーコードには型がない。

後発のtypedclojureも出ていますが、開発が非常に盛んというわけではなく、基本的な方針は変わっていないようです。 また、より広く使われているschema.coreを採用している企業にとって、移行コストがかなり高くつくことが予想されます。

4. malli + clj-kondo

これは今回やろうとしていることに近いかと思います。 スキーマとバリデーションの機能を提供するmalliというライブラリが、clojureのリンタである clj-kondoと連携することによって、実行前にスキーマを静的に解析した結果を知ることが出来るようです。

github.com

特に大きな欠点はなさそうですが、強いて言えば、他のスキーマライブラリを使っているプロジェクトにおいては移行コストが高く付きそうです。

結論

ということで、既存でも型を解析するツールチェインは存在していますが、トヨクモでも利用していて、比較的広く使われているschema.coreのスキーマを静的解析する、ということには意義がありそうです。

大まかな方針として、既にあるものから漸進的に解析出来る情報を増やしていけば、既存コードに影響を与えることなく、静的解析で得られる情報を増やしていけるのではないか、という算段です。

サードパーティーコードに型がないという問題点は致し方がないと思いますが、他の例を見てみて、解析に必要な情報が足りないときにエラーを出したり、明示的に無視をするのを要求するのではなく、Any(なんでも許容する)という寛容な方に倒し、間違っている確証が得られる箇所のみをエラーとするベストエフォート方式が良いのではないかと思いました。

とはいえ、型が分からないものをそのままにしておくと、Anyが伝播していって全体的に型付けが弱くなってしまったり、Anyの場合にバグを検出することが出来ないという問題があります。 この問題に関しては、型アノテーションで補足をすることで解決出来るかと思います。静的解析が出来ない場合はschema.coreの実行時解析が役に立ちそうです。

速度についてですが、例えば、HTTPのパース、JSONのパースを始めとする何度も実行されるコードはやはり高速であればあるほど良く、そうある努力をすべきだと思っています。 コードの解析もそれに含まれるのではないかと思います。

今回は開発言語にRustを用います。 解析の性質上、取りうる型を網羅的に検査する為に、Rustの強い型付けや代数データ型は非常に有用です。また、解析の速度におけるオーバヘッドにならず、特に意識しなくてもそこそこ高速なことを期待しています。

読み進める前に

この記事は、Clojureを静的型付け言語にした際の機能や実装方法の概要を紹介するものですが、あまり実装の詳細を書いていくと理解しづらくなってしまうため、実装の代わりに「どのようなデータ構造を扱っているのか」という側面で解説していきます。 また、実装の詳細が気になる方の為に、合わせて実際のコードのリンクを近くに貼っておきます。

1. Clojureのパーサーを作る。

解析の準備の為に、Clojureのソースコードの文字列から、Clojureの構文を表すデータ構造に変換する必要があります。文字列からそのようなデータ構造に変換する機構のことをパーサーと呼びます。(大雑把な説明)

Rust製Clojureパーサーは無い気がしたので、まずはClojureのパーサーを作ります。 本題ではありませんので実装方法は割愛しますが、例えば、以下のソースコードを次のように変換出来るように実装します。

(s/defn add-one :- s/Str ;; サンプルコード
  [a :- s/Int]
  (+ a 1))
List(
  Symbol {ns: Some("s"), name: "defn"},
  Symbol {ns: None, name: "add-one"},
  Keyword { ns: None, name: "-"},
  Symbol {ns: Some("s"), name: "Str"},
  Vector (
     Symbol {ns: None, name: "a"},
     Keyword {ns: None, name: "-"},
     Symbol {ns: "s", name: "Int"}
  )
  List (
     Symbol {ns: None, name: "+"},
     Symbol {ns: None, name: "a"},
     IntegerLiteral(1)
  )
)

このとき、コードに影響を及ぼさないコメントや空白などの情報は削ぎ落とされています。 このようなデータ構造を、言語処理系の用語で「抽象構文木」と呼びます。

英語で AST(abstract syntax tree)と略すことが多いので、以降はASTという言葉を使います。

詳細を見たい方は以下のファイル内の型を見るとよりイメージが湧くかもしれません。実際は、位置の情報なども含まれています。

github.com

2. ASTを解析しやすいデータ構造に変換する

先程パースしたデータはあくまでリストの中にシンボルなどの下位のASTが入っているものに過ぎません。 つまり、関数ということが分かっているのではなく、「ただのリスト」に過ぎないのです。

他のLisp以外の言語は構文の種類がより多く、ASTの時点で意味合いが具体的に決まっていることが多いため、文字列からパースした段階でその情報を得られることが多いですが、Clojure(Lisp)は構文がリスト、ベクタ、リテラルぐらいしかなく、自由度が非常に高いので、ここから更に意味を抽出していきます。

Clojureの内部構造としては、def, defn, if, let などの特殊形式はマクロとして定義されていますが、これを展開して解析しようとするとキリが無いので、ある程度決め打ちでこちらが分かる範囲のデータに持ち込むというわけです。

先程のASTから以下のようなデータを生成します。

Function (
   decl: FunctionDecl,
   name: "add-one",
   arguments: [
      (Binding::Simple("a"), Some(Scalar("Int"))) // Int型
   ]
   return_type: Some(Scalar("Str")),
   body: [
     Call {
       func_expr: Symbol {ns: None, name: "+"},
       args: [
          SymbolRef {ns: None, name: "a"]
          IntegerLiteral(1)
       ]
     }
   ]
)

例えば、defnから始まるリストが、Function、それに続くシンボルが名前、その後のベクタが引数、 + から始まるリストが Call、というように、defnマクロが持つ意味に従って再解釈されています。

他にも特殊形式はあると思います。例えば、ifやwhenなどがありますが、これらもそれぞれのマクロが持つ意味に従って解釈していきます。

詳細を見たい方は以下のファイル内の型を見るとよりイメージが湧くかもしれません。

github.com

3. データを用いて型を解析していく

さて、ここまで作成してきたデータにより、型を解析するための準備は揃っています。 後は以下のことを出来るようにするだけです

  • 関数、変数、スキーマの型解決
  • 型シンボルを比較可能な形式に解決
  • 型が他の型に代入できるかのロジックの実装
  • 関数呼び出し、定義、代入、関数の戻り値などに関して、型が定義されたものに一致するかを検査する

関数、変数、スキーマの型解決について 簡単な実装方法を紹介します。 変数の参照先の解決という問題は、「スコープ」という概念と、あるスコープの中で参照出来る変数というデータを表現することによって解決することが出来ます。

例えば以下のような 関数定義があったとします

(s/defn say-hello [to :- s/Str]
   (println to)

このとき、[to :- s/Str] の後の (println to) の部分が関数のボディにあたりますが、この中では、to という変数を参照出来るようになっていて、s/Str型を持っているということも分かるはずです。 この情報を defn に対応するデータを読み込んだ際にマップに保存していきます。

スコープはネストされていくものなので、深さによって変数が指す値が変わることがあります。そのため、マップの可変長配列 というデータ型で、関数や変数、スキーマを解決するためのデータを表現することが出来ます。

以下に実際のコードの関数の解析の例を載せておきます。

pub fn analyze_function(errors: Errors, context: Context, func: &Function) {
    let func_ty = get_func_type(context.clone(), &func.decl);
    context
        .borrow_mut()
        .variable_scopes
        .last_mut()
        .unwrap()
        .insert(func.decl.name.clone(), func_ty);
    variable_scope!(context, {
        for (arg_binding, opt_arg_ty) in &func.decl.arguments {
            match &arg_binding.value {
                semantic_parser::semantic_ast::Binding::Simple(name) => {
                    let arg_ty = if let Some(arg_ty) = opt_arg_ty {
                        context.borrow().resolve_type(&arg_ty).clone()
                    } else {
                        ResolvedType::Unknown
                    };
                    context
                        .borrow_mut()
                        .variable_scopes
                        .last_mut()
                        .unwrap()
                        .insert(name.clone(), arg_ty);
                }
                semantic_parser::semantic_ast::Binding::Complex { keys, alias } => todo!(),
            }
        }
        for expr in &func.exprs {
            analyze_expression(errors, context.clone(), expr);
        }
    });
}

このような関数を書いていきます。特にClojureは ifもwhenも様々なものが「値」なので、値の解析については再帰的に解析することになります。 その際も let forなど変数を束縛するマクロがあれば、スコープを追加して値を設定していきます。そして、それらの値も解析が終わる際にスコープをpop(削除)します。

この実装により変数などの型が解決出来るので、例えば、関数呼び出しの際に、以下のように型が間違っているかどうかが判定出来るようになります。

(say-helllo ;; s/Strを受け取る関数であることが分かっている
  1) ;; リテラルはそのまま s/Intであることが分かるので、これはエラーとなる

実際の実装のリンクも貼っておきます。

github.com

github.com

エラーを標準出力する

以上で紹介した実装方法によって、型が間違っている箇所をエラーとして蓄積していきます。 あとは集まったエラーを標準出力に出力するだけです。以下のようなイメージです。

for error in errors {
   println!("{}:{}:{}: {}", file_path, error.location.line, error.location.col, error.message)
}

エディタで表示する

標準出力だけでも有用かもしれませんが、普段の開発に組み込むことを考えれば、エディタ上でエラーを表示出来るのが望ましいです。 今回は弊社で広く使われている IntelliJ での方法を紹介します。

まず、IntelliJのプラグインであるFileWatcherを導入します。

設定 > ツール > FileWatchersを開き、リストに設定を追加します。

肝はこの部分です。この部分を標準出力の形式に合わせるだけで、保存時に出た標準出力をパースして、該当位置に波線が引かれるようになります。

ホバーすると、$MESSAGE$ の内容のツールチップを表示してくれます。

最後に

今回は、Clojureのパーサーから書いた為に結構な実装量になりましたが、これでClojureの静的解析をするための基礎が出来ました。まだ初歩的なものだしバグも多いかもしれませんが、このプログラムを漸進的に成長させて、実際に業務で使えるところまで持っていきたいという所存でいます。

他にも

  • 他のファイルやライブラリの読み込み、namespaceの解決
  • 標準ライブラリへの組み込み型付け
  • サードパーティーコードへの型付けの提供

などやることが多く考えられます。 もし、Clojureの静的解析に興味を持っていただけた方は、ご意見、感想を頂けると励みになります。 最後まで読んで頂きありがとうございます。


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

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

Babashka pods から使う clj-kondo

開発本部の飯塚です。

この記事は Clojure Advent Calendar 2022 5日目の穴埋めに向けた記事です。

今回は clj-kondo を Babashka pods として利用する方法を簡単に解説したいと思います。

用語の説明

clj-kondo

clj-kondo とは何か、どう使うのかについては以下の記事でまとめているので もし知らなければ先にこちらを参照することをおすすめします。

tech.toyokumo.co.jp

Babashka

Babashka は Clojure のインタプリタです。 GraalVM を使ってネイティブイメージとして動くため起動がとても速く Clojure の機能のほとんどがそのまま使えるためスクリプトとしての用途などで広く利用されています。

Babashka pods

Babashka pods とは Babashka にて Clojure ライブラリとして使える「プログラム」です。

あえて強調した通り「プログラム」なので Clojure で書かれている必要は勿論ありません。 Go で作られたものもあれば C# で作られたものもあります。

Babashka pods として動くプログラムは nREPL をベースとしたプロトコルで Babashka とやりとりし動作します。 このプロトコルでは nREPL 同様に Bencode を使っていて、メッセージのやりとりは標準入出力で行われます。 なので基本的には Bencode さえ扱えればどの言語でも Babashka pods として動くプログラムは作れます。

参考までに拙作の Dad でも Babashka pods に対応しているので、その部分へのリンクだけ貼っておきます。 https://github.com/liquidz/dad/blob/main/src/dad/pod.clj

Babashka pods としての clj-kondo

前置きが長くなりましたが、clj-kondo は Babashka と同じ Borkdude 氏によるものなので当然 Babashka pods としても動きます。

一応 clj-kondo 自体が clojars にデプロイされているのでライブラリとしても勿論利用できはするのですが、 Babashka pods として利用することで手軽に便利スクリプトが作れるのでその利点と方法が紹介できればと思います。

今回はあるプロジェクト配下で private にできそうな public var を検出するスクリプト を例に説明します。

準備

まずは準備です。例えば foo.clj のようなファイルを用意してみましょう。 ファイルの保存先は任意のプロジェクトルート直下を想定しています。

(ns foo
  (:require
   [babashka.pods :as pods]))

;; Babashka pods として clj-kondo を読み込む
;; clj-kondo コマンドへのパスが通っている必要あり
;; もし clj-kondo コマンドを持っていない場合は Pod registry も利用可能
;; https://github.com/babashka/pod-registry
(pods/load-pod "clj-kondo")

;; 読み込んだ pod で提供されている ns を require
(require '[pod.borkdude.clj-kondo :as clj-kondo])

これだけで Babashka pods として clj-kondo を使う準備は完了です。 clojars のライブラリから使う場合は Leiningen や Clojure CLI を使って project.clj なり deps.edn なりから用意する必要がありますが、 Babashka pods から使う場合はファイル1つだけなのでかなり手軽であることがわかると思います。

エラーが無いかは bb コマンドを使って bb foo.clj のように実行しても確認はできますが、 いくら Babashka の起動が速いとは言っても非効率なので Clojure 開発環境から Babashka の REPL に接続することをおすすめします。 そうすることでフォーム単位での評価ができ、 REPL 駆動でスクリプトを書くことが可能になります。

なお最近の Clojure 開発環境であれば大抵は Babashka に対応しているはずなので、 どうやって Babashka の REPL に接続するのかは各開発環境のドキュメントを参照してください。 例えば拙作の vim-iced では IcedInstantConnect コマンドでREPLの起動と接続が可能です。

なおもし Clojure の開発環境がない場合は以下のまとめ記事を参考にすると良いでしょう。

tech.toyokumo.co.jp

プロジェクトの解析

次に実際に clj-kondo を使って解析データを取得してみましょう。

:lint で指定しているディレクトリは検出したいプロジェクトに応じて変更してください。 今回説明するスクリプトの中で一番時間がかかるのがここの clj-kondo による解析処理なので、例えば analysis-data として束縛しておけばその後の解析データを使った処理でデータ構造の確認や情報の抽出が楽になります。

(def analysis-data
  (-> {:lint ["src"]
       :config {:output {:analysis true}}}
      (clj-kondo/run!)
      (:analysis)))

public な var の抽出

解析データが取得できたら次は public な var の抽出です。

今回は :var-definitions という var の定義情報を利用します。 解析データに他にどのようなものが含まれるのかの詳細は clj-kondo のドキュメントを参照してください。

(def public-vars
  (->> (:var-definitions analysis-data)
       ;; public なものだけにしたいので private は除外
       (remove :private)
       ;; 必要な情報(ns名, var名)だけにする
       (map #(select-keys % [:ns :name]))
       (distinct)))

結果の出力

これで最後です。

「privateにできそう」というのは言いかえると「varが定義されているns以外で使われていない」ということです。 それをそのまま条件として書き出して、該当する var を出力します。

var の利用状況は解析データの :var-usages にあります。

(doseq [v public-vars]
  (let [;; public な var を使っている箇所を抜き出す
        usages (filter #(= (:name v) (:name %)) (:var-usages analysis-data))]
    ;; var の利用元の ns 名がすべて定義されている ns と一致するならば、それは「privateにできそうな var」
    (when (every? #(= (:ns v) (:from %)) usages)
      (println (format "%s/%s" (:ns v) (:name v))))))

これで public だけど private にできそうな var が表示できました。

勿論ライブラリ等のコードで意図的に public にしているものも表示されてしまうとは思いますが、 例えば public-vars の抽出時に特定の var は除外するみたいなことは自由にできるので、自身のプロジェクトに応じてカスタマイズすれば使えるスクリプトになるかと思います。

これ以外にも例えば以下のようなことも可能です。

  • テストコードの無い public var を表示する
  • ns のエイリアスとして他ファイルと異なるエイリアスを使っている箇所を表示する

今回紹介したスクリプトの全体は以下の Gist に保存してありますので、とりあえず試してみたい方はこちらからコピーしてください。

gist.github.com

最後に

clj-kondo の解析データはコマンドからも取得可能で、それをパイプしてワンライナーであれこれする方法もありますが、 試行錯誤のしづらさが個人的にネックでした。

それと比べて以上のようなことが1ファイルで、かつ REPL 駆動で書けるのは嬉しい人も多いのではないでしょうか?

clj-kondo は解析データからはいろいろな情報が取得可能なので、この記事内で紹介したこと以外でも便利な使い道があるはずです。

リンターであるという認識が強いのか解析データを利用する方面での記事はあまり多くない印象なので、これを機にこんなこともできて便利だよ!という記事が増えたら良いなと思います。


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

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

Thanks OSS Award というサイトを公開しました

Thanks OSSチーム チームリーダーの飯塚です。

タイトルの通りですがこの度、Thanks OSS Award というサイトをついに公開できたのでその紹介です。

Thanks OSS Award とは

Thanks OSS Award とは私達の製品開発で使用しているライブラリ/ツールなどにおいて、 年度単位で選出した開発者の方々にその開発の継続を金銭的に支援しましょうという活動です。

oss.toyokumo.co.jp

上記サイトに詳細は書かれていますが、この活動のポイントは「継続」です。 新規機能の追加や問い合わせサポートではなく、今までやっていた開発を今後も同じように継続してもらうことを目的とした支援になっています。

よって見返りを求めるための支援でないことは予めご承知おきください。

私自身、業務外にてOSSを開発している身ですが、様々な要因で継続することの大変さを実感しています。 特に子どもが生まれたことにより自由時間が大きく減ったことで、アウトプットの速度は大きく下がりました。 それでも朝早くに起きて、子どもが寝ている時間を開発に当てるなどして継続できているのは「楽しい」という気持ちだけではなく、 スポンサーとして応援してくれている方々の存在がモチベーション維持に少なからず影響しているためだと感じています。

OSSは私達の製品を形作る重要なピース。 そのピースを作ってくれている方々のモチベーション維持に少しでも貢献できればというのが OSSを使わせてもらっている私達の願いです。

究極の目標

活動の背景/目的、また実際の選考結果などはサイトの方にまとめてありますのでそちらを参照していただきたいですが、 2022年度の支援金額だけこちらでも紹介させてもらうと $63,150 になりました。

1ドル144円換算だと 9,093,600 円です。

決して安い金額ではありませんが、複数の開発者の方々に支援する金額としてはこれだけで生活できるレベルではありません。 そう、OSS開発者がその開発だけで生活できる未来を築けたらというのが私達が考える究極の目標です。

その目標には私達だけではたどり着けないかもしれません。 すでにOSS含め多方に支援されている企業もいますが、OSSを利用していてそこに支援している企業というのはまだ少ないのではないでしょうか?

多くの企業がそれぞれお世話になっているOSS開発者の方々を支援することでこの未来は近づいてくると思います。 そのときにこの Thanks OSS Award という活動が支援するきっかけの1つにでもなると嬉しいです。

活動裏話

元々OSSへの支援は以下のブログ記事で書いてあるとおり2020年から行ってきました。

tech.toyokumo.co.jp

その頃からの私達のOSSへの思いは変わっていませんが、「今後の予定」に書いていた通り支援対象は拡大したいという考えはありました。 その考えのもと Thanks OSS Award は今年04月頃から動き始めました。

支援したい方の選出は普段お世話になっている気持ちがあったのでそこまで難航しなかったのですが、 支援方法の確保で少し手間がかかったので裏話として紹介します。

GitHub Sponsors が有効でない

支援方法としては2020年から実際に行ってきた GitHub Sponsors をそのまま採用しました。 ただ支援した方の中には GitHub Sponsors が有効でない方もいたので、その方には直接コンタクトをとって有効にできないかという相談をさせてもらいました。

私自身 GitHub Sponsors を有効にしている身としては地味に面倒な申請があったことから(日本のみ?)、有効に難色を示されるかもと思っていたのですが 何回かのやりとりの上で色よい返事をもらえた方がいたことは純粋に嬉しかったです。

なお GitHub Sponsors がまだサポートしていない国であるために有効にできないという方がいたのも事実です。 その方は残念ながら今年度の対象からは除外となってしまったのですが、 将来 GitHub Sponsors がサポートした暁には改めて支援対象として追加したいと考えています。

GitHub Sponsors の任意金額の開放

GitHub Sponsors は支援のTier(コース)を決める必要があります。 なので一番単純なのは「$N/月」、「$M/月」などのコースから選んで支援する形となるのですが、 今回支援金額はこちらで決めたかったので、このような設定の方にも任意の金額が指定できるように設定変更を直接相談させてもらいました。

コンタクトをとる手段を探す手間は同様ですが、どう相談したらいいものかがとても悩ましかったです。 というのも「$Nくらい支援したいので設定変更してください」と見知らぬ人から急に連絡がきても怪しいだけだからです。

とはいえ相談したい内容はそのとおりなので、所属だけでなく個人のアカウントなども紹介した上で 極力怪しさを払拭して(したつもりになって)やりとりをすることで設定変更していただけたのかなと思っています。

最後に

動き始めてから約半年、ようやく形になりましたが、最初に書いたとおりサイト公開が目的ではなく私達が使っているライブラリ/ツールなどの開発継続の支援が目的です。 つまり Thanks OSS Award はまだ始まったばかりです。

来年度以降も継続して行われる活動であるので、これらを通してOSS開発者の方々のモチベーション維持に貢献できればと願っています。

oss.toyokumo.co.jp

シンプルな例で理解するWASMの基礎の基礎

こんにちは。開発本部の松尾です。 今回は、趣味でWebAssemblyをターゲットにしたコンパイラを作った知見を元に、WebAssemblyについての解説記事を書きました。

WASMとは?

WebAssemblyの略で、Webプラットフォーム上で実行出来るように設計された、命令セット及び言語のことです。 Webにおいて広く使われている JavaScriptが、文字列からパースされて動的に実行されているのに対して、 WASMは、機械語に近い状態にコンパイルされたコードをバイナリ形式で配信することが出来る為、多くのケースにおいて、JavaSciptよりも高速に動くように設計されています。

WASMの用途やメリット

WASMの機能はJavaScriptに比べてかなり限定されており、基本的にWASMのみでは一般的なCPUがサポートしている計算を行うことしか出来ません。例えば、ファイルアクセスや通信などのIO機能に直接アクセスすることは出来ません。

WASMには、

  • JS以外の様々な言語をWASMにコンパイルしてブラウザ上で実行出来る
  • (多くの場合)高速に動作する

という性質がある為、

  • C, C++等JavaScript以外の言語をコンパイルして、既存のコードをWebプラットフォーム上で使う
  • 高速な処理が必要となるサービス体験をブラウザ上で実現し提供する

というユースケースが主になっています。

今回やること

非常に単純な WASMをテキスト形式で書き、実際にブラウザで実行することで、短時間でWASMの実態を理解し、身近に感じられるようになることを目指します。

テキスト形式について

WASMは時に、ブラウザで動く「言語」と説明されることがありますが、WASMには、バイナリ形式とテキスト形式両方が定義されており、そのうちテキスト形式は、WAT (WebAssembly Text Format) と呼ばれています。

バイナリ形式はご想像の通り、テキストでは無いバイナリデータの塊ですが、 テキスト形式は以下のような、LispのようにS式で構造化された言語で、意味的にはバイナリ形式とほぼ対応しています。

以下は引数の階乗を計算する WATです。

(module
  (func $fac (export "fac") (param f64) (result f64)
    local.get 0
    f64.const 1
    f64.lt
    if (result f64)
      f64.const 1
    else
      local.get 0
      local.get 0
      f64.const 1
      f64.sub
      call $fac
      f64.mul
    end))

見た目はLispに似ているかもしれませんが、CPUの命令セットを大抵のアーキテクチャに当てはまるように抽象化した命令をサポートするよう設計されている為、内容は低レイヤーで、アセンブリ言語に近いものになっています。

ただし、多くのアセンブリ言語と異なりメモリの読み書きの最小単位は、スタックというものに抽象化されています。スタックを用いた計算モデルのことをスタックマシンと呼びます。

スタックマシンの詳しい説明はリンク先に譲り、ここでは簡単な例で説明したいと思います。

例えば、1 + 1 を計算するプログラムは以下のようになります。

i32.const 1
i32.const 1
i32.add

これを関数にすると、WATは以下のようになります

(func $add (result i32)
  i32.const 1
  i32.const 1
  i32.add)

これだけです。

WATでは、(result i32) のように戻り値を宣言します。 WASMで扱える値は基本的には以下の4つのみです。

i32, i64, f32, f64

例えば、i32は 32ビットの整数を表します。

これに対して、 i32.const などの定数の宣言や、 i32.add などの演算が定義されている命令セットがWASMの実態です。

引数を2つ受け取り、それらを足して返す関数は以下の通りです。

(func $add (param i32 i32) (result i32)
  local.get 0
  local.get 1
  i32.add)

なんとなく、感覚で分かってきたのではないでしょうか。

では、これを実際に動かすことを考えていきましょう。 WASMでの最小の実行可能な単位は、moduleです。 モジュールの定義自体は非常に単純で、例えば空のモジュールは以下のように定義されます。

(module)

拍子抜けするほど簡単ですが、このモジュールの中に、関数を入れれば実行出来るようになります。

(module
  (func $add (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add))

ただし、WASMを外の実行環境(今回の場合ブラウザ上のJS)から呼べるようにするためには、export で明示的に外向けの名前を宣言する必要があります。

(module
  (func $add (export "add") (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add))

これで、本当にブラウザから直接実行出来るWASMに対応したWATが出来ました。

WATは意味的にWASMのバイナリ形式に対応しているだけで、ブラウザが直接解釈して実行するものではないので、これをコンパイルすることになります。*1

今回は、手早く試すために、wat2wasm demo を用いて実行してみましょう。

画像のように、左上にWATのコードを書き、左下にJavaScriptを書きます。

wat2wasm demo

JavaScript は以下のようになります。

const wasmInstance =
    new WebAssembly.Instance(wasmModule, {});
const { add } = wasmInstance.exports;

console.log(add(1,2))

裏側で、上のWATをコンパイルして、インスタンスにしたものを wasmModule に束縛するというマジックがありますが、実際のJavaScriptのコードも大体同じようなものになります。

詳しくは、MDNを参照してみてください。

developer.mozilla.org

さて、このデモはリアルタイムでコンパイルされるので、WATとJSを正しく書き終えた時点で右側に結果が現れているはずです。 右上は、対応するWASM(バイナリ形式) 右下が実行結果の出力です。

以上が、最も単純なWASMのチュートリアルでした。

Real World WASM

WASMが実際にどのようなものであるか、非常にシンプルながら、お分かりいただけたかと思います。

もちろん実際にアプリケーションで使われるWASMはもっと複雑で大きなものになりますが、上で紹介したような、CPUレベルに近い演算内容をブラウザが実行出来ることにより、以下のように、JSだけでは現実的ではなかった様々なことを実現することが出来るようになります。

  • 音声、画像などのメディアの処理
  • 時間のかかるアルゴリズムを短縮
  • メモリレイアウトを意識した最適化
  • 言語のコンパイラをブラウザ上で実行

Webの世界でも、ネイティブアプリケーションと遜色無いアプリケーションが作れるかもしれないと考えると夢が広がりますね。お読みいただきありがとうございました。


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

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

*1:他の言語が「WASMに対応する」ということは、基本的に、この形式に対応したバイナリをコンパイルのターゲットとして出力出来る、ということを意味しています。

Clojureで作るAPI RESTful APIを追加する

[連載]Clojureで作るAPIの12記事目です。

前回の記事はこちらです。

tech.toyokumo.co.jp

前回記事ではRing Middlewareを追加してAPIとしての体裁を整えました。 今回はRESTful APIとして振る舞うために足りない仕組みの整備をしていきます。

RESTful APIに必要な残りの要素

実際のところこれまでの実装によって、ルーティングやJSONのエンコード/デコードなどAPIとしては十分な機能を備えるようになってきました。

その上でRESTful APIとして不足している機能としてはリクエストの

  • パスパラメーター(URLに含まれるパラメーター)
  • クエリパラメーター
  • リクエストボディ

の制御があります。以降ではこの3つをまとめてリクエストパラメーターと呼ぶことにします。

これらのリクエストパラメーターを制御する上で必要なことは2種類あります。

  1. APIが要求する型の形式(契約)を満たしているか
  2. データの重複排除など、データの内容が受け付けられるものか

これら2つは性質の異なるものであるため分けて考えた方がいいというのが筆者の私見です。

1つ目はクライアントのコードが満たすべき領域で開発上の問題であるため、 エラーレスポンスの受け取り手は開発者でありエンドユーザーではないはずです。 そのためある程度画一的かつリクエスト形式の修正に役立つ情報があれば良いはずです。

2つ目はユーザーの自由な入力を想定するものに対して、リクエスト形式は正しいもののビジネスロジックに依存してエラーとなっているものです。 APIのクライアントはこのエラーレスポンスを使ってユーザーに対してエラーの内容を伝えます。 従ってエラーの内容は一定以上詳細でかつ「次にどうすればいいのか」がわかるものであるべきです。

パスパラメーターが1番に該当し、クエリパラメーターとリクエストボディが2番に該当すると考えます。

以降ではこれら2つの検証と制御ができるようにしていきます。

パスパラメーターの制御

reititのcoercion機能を使います。

reititでは型を記述するための外部ライブラリとして複数の選択肢がありますが、 この連載では上記のreititのドキュメントでも使われており、 弊社でも使用しているprismatic/schemaを使用します。

何らかの会員管理をしているアプリケーションだとしてその人のページがあるとしましょう。 そんなパスを次のように定義してみます。

;; src/cljapi/router.clj
(ns cljapi.router
  (:require
   [camel-snake-kebab.core :as csk]
   [cljapi.handler :as h]
   [clojure.core.memoize :as memo]
   [muuntaja.core :as muu]
   [muuntaja.middleware :as muu.middleware]
   [reitit.coercion.schema]
   [reitit.ring :as ring]
   [reitit.ring.coercion :as rrc]
   [ring.middleware.defaults :as m.defautls]
   [schema.core :as s]))

;;; (略)

(def router
  (ring/router
   [["/health" {:name ::health
                :handler h/handler}]
    ["/api" {:middleware [[m.defautls/wrap-defaults ring-defaults-config]
                          [muu.middleware/wrap-format muuntaja-config]
                          muu.middleware/wrap-params
                          ;; middlewareを2つ追加
                          rrc/coerce-exceptions-middleware
                          rrc/coerce-request-middleware]
             ;; /api以下では型定義に基づいて変換するようにするための設定
             :coercion reitit.coercion.schema/coercion}
     ["/hello" {:name ::hello
                :handler h/handler}]
     ["/goodbye" {:name ::goodbye
                  :handler h/handler}]
     ;; :idで/accountの後にくるのがパスパラメーターであることを示している
     ["/account/:id" {:name ::account-by-id
                      ;; :idはInteger(java.lang.Long)であることを宣言
                      :parameters {:path {:id s/Int}}
                      ;; PUTとDELETEがあると定義
                      :put {:handler h/handler}
                      :delete {:handler h/handler}}]]]))

追加したrouteに対応するHandlerを用意します。

;; src/cljapi/handler/api/account.clj
(ns cljapi.handler.api.account
  (:require
   [cljapi.handler :as h]
   [cljapi.router :as r]
   [ring.util.http-response :as res]))

(defmethod h/handler [::r/account-by-id :put]
  [req]
  (res/ok {:method :put
           :path-params (:path-params req)}))

(defmethod h/handler [::r/account-by-id :delete]
  [req]
  (res/ok {:method :delete
           :path-params (:path-params req)}))

新しくHandlerを作ったら忘れずにrequireしておきます。

;; src/cljapi/component/handler.clj
(ns cljapi.component.handler
  (:require
   [cljapi.handler.api.account] ; 追加
   [cljapi.handler.api.greeting]
   [cljapi.handler.health]
   [cljapi.router :as router]
   [com.stuartsierra.component :as component]
   [reitit.ring :as ring]
   [ring.logger :as m.logger]
   [ring.middleware.lint :as m.lint]
   [ring.middleware.reload :as m.reload]
   [ring.middleware.stacktrace :as m.stacktrace]))

;;; (略)

ここまでかけたら再評価するかREPLを再起動して (go) した後でリクエストを投げて確認してみます。 レスポンスのJSONをみやすくするためにjqを使用しています。

$ curl -X PUT http://localhost:8000/api/account/123 |jq .
{
  "method": "put",
  "pathParams": {
    "id": "123"
  }
}

$ curl -X DELETE http://localhost:8000/api/account/123 |jq .
{
  "method": "delete",
  "pathParams": {
    "id": "123"
  }
}

$ curl -X PUT http://localhost:8000/api/account/abc |jq .
{
  "schema": {
    "id": "Int",
    "Keyword": "Any"
  },
  "errors": {
    "id": "(not (integer? \"abc\"))"
  },
  "type": "reitit.coercion/request-coercion",
  "coercion": "schema",
  "value": {
    "id": "abc"
  },
  "in": [
    "request",
    "path-params"
  ]
}

失敗時にはエラーの内容が返されており、リクエストのどこが間違っているのかわかります。

バリデーション

次にバリデーションをできるようにしていきます。 そのためにstructを使います。

github.com

まずライブラリを追加します。

;; deps.edn
{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"}
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}
        metosin/reitit {:mvn/version "0.5.18"}
        metosin/ring-http-response {:mvn/version "0.9.3"}
        ring/ring-devel {:mvn/version "1.9.5"}
        ring-logger/ring-logger {:mvn/version "1.1.1"}
        ring/ring-defaults {:mvn/version "0.3.3"}
        metosin/muuntaja {:mvn/version "0.6.8"}
        camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"}
        org.clojure/core.memoize {:mvn/version "1.0.257"}
        ;; 追加
        funcool/struct {:mvn/version "1.4.0"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.68.1059"}}
                  :main-opts ["-m" "kaocha.runner"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

本項で使うためのルート定義を追加します。

;; src/cljapi/router.clj
(ns cljapi.router
    (:require
     [camel-snake-kebab.core :as csk]
     [cljapi.handler :as h]
     [clojure.core.memoize :as memo]
     [muuntaja.core :as muu]
     [muuntaja.middleware :as muu.middleware]
     [reitit.coercion.schema]
     [reitit.ring :as ring]
     [reitit.ring.coercion :as rrc]
     [ring.middleware.defaults :as m.defautls]
     [schema.core :as s]))

;;; (略)

(def router
  (ring/router
   [["/health" {:name ::health
                :handler h/handler}]
    ["/api" {:middleware [[m.defautls/wrap-defaults ring-defaults-config]
                          [muu.middleware/wrap-format muuntaja-config]
                          muu.middleware/wrap-params
                          rrc/coerce-exceptions-middleware
                          rrc/coerce-request-middleware]
             :coercion reitit.coercion.schema/coercion}
     ["/hello" {:name ::hello
                :handler h/handler}]
     ["/goodbye" {:name ::goodbye
                  :handler h/handler}]
     ;; 追加
     ["/account" {:name ::account
                  :get {:handler h/handler}
                  :post {:handler h/handler}}]
     ["/account/:id" {:name ::account-by-id
                      :parameters {:path {:id s/Int}}
                      :put {:handler h/handler}
                      :delete {:handler h/handler}}]]]))

まずstructのドキュメントを参考にしながらプロジェクト独自のバリデーターを作成します。 これはエラーメッセージのカスタマイズなどをするためにも必要になります。

;; src/cljapi/validator.clj
(ns cljapi.validator
    (:require
     [clojure.string :as str]
     [struct.core :as st]))

(def required
  "structのrequiredに加えて、文字列の場合に空白のみの文字列を許容しないvalidator"
  {:message "必須入力項目です"
   :optional false
   :validate #(if (string? %)
                (boolean (seq (str/trim %)))
                (some? %))})

(def string
  [st/string
   :message "文字列である必要があります"
   :coerce str/trim])

(def string-shorter-than-256
  [st/max-count 255 :message "255文字以内で入力してください"])

(def email
  [st/email :message "メールアドレスの形式が正しくありません"])

これらを使ってバリデーション関数を実装します。

;; src/cljapi/validator.clj
(ns cljapi.validation.account
  (:require
   [cljapi.validator :as v]
   [struct.core :as st]))

(def ^:private get-account-schema
  {:search [v/string v/string-shorter-than-256]})

(def ^:private post-account-schema
  {:name [v/required v/string v/string-shorter-than-256]
   :email [v/required v/string v/email]})

(defn validate-get-account
  [{:keys [:params]}]
  (st/validate params get-account-schema {:strip true}))

(defn validate-post-account
  [{:keys [:params]}]
  (st/validate params post-account-schema {:strip true}))

バリデーションの対象にはクエリパラメーターとリクエストボディがあるのに :params だけを見ているのは、 すでに設定済みのRing middleware によって2つが :params にマージされるためです。

テストを書きながら使用例を見ていきます。

;; test/cljapi/validation/account_test.clj
(ns cljapi.validation.account-test
  (:require
   [cljapi.validation.account :refer [validate-get-account validate-post-account]]
   [clojure.string :as str]
   [clojure.test :refer [deftest testing is]]))

(deftest validate-get-account-test
  (testing "正常系"
    (is (= [nil
            {:search ""}]
           (validate-get-account {:params {:search ""}})))
    (is (= [nil
            {:search "foo"}]
           (validate-get-account {:params {:search "foo"}})))
    (is (= [nil
            {:search (str/join (repeat 255 "a"))}]
           (validate-get-account {:params {:search (str/join (repeat 255 "a"))}}))))
  (testing "異常系"
    (is (= [{:search "文字列である必要があります"}
            {}]
           (validate-get-account {:params {:search 123}})))
    (is (= [{:search "255文字以内で入力してください"}
            {}]
           (validate-get-account {:params {:search (str/join (repeat 256 "a"))}})))))

(deftest validate-post-account-test
  (testing "正常系"
    (is (= [nil
            {:name "トヨクモ"
             :email "hoge@example.com"}]
           (validate-post-account {:params {:name "トヨクモ"
                                            :email "hoge@example.com"}})))
    (is (= [nil
            {:name (str/join (repeat 255 "a"))
             :email "hoge@example.com"}]
           (validate-post-account {:params {:name (str/join (repeat 255 "a"))
                                            :email "hoge@example.com"}})))
    (is (= [nil
            {:name "トヨクモ"
             :email "hoge@example.com"}]
           (validate-post-account {:params {:name "トヨクモ"
                                            :email "hoge@example.com"
                                            :foo "bar"}}))
        "余計なキーは除外される"))
  (testing "異常系"
    (is (= [{:email "必須入力項目です"
             :name "必須入力項目です"}
            {}]
           (validate-post-account {:params {}})))
    (is (= [{:email "文字列である必要があります"
             :name "文字列である必要があります"}
            {}]
           (validate-post-account {:params {:name 1
                                            :email 2}})))
    (is (= [{:name "255文字以内で入力してください"
             :email "メールアドレスの形式が正しくありません"}
            {}]
           (validate-post-account {:params {:name (str/join (repeat 256 "a"))
                                            :email "hoge"}})))))

これを使ってHandlerを実装しましょう。

;; src/cljapi/validation/account.clj
(ns cljapi.handler.api.account
  (:require
   [cljapi.handler :as h]
   [cljapi.router :as r]
   [cljapi.validation.account :as v.account]
   [ring.util.http-response :as res]))

(defmethod h/handler [::r/account :get]
  [req]
  (let [[error values] (v.account/validate-get-account req)]
    (if error
      (res/bad-request error)
      (res/ok {:validated-values values}))))

(defmethod h/handler [::r/account :post]
  [req]
  (let [[error values] (v.account/validate-post-account req)]
    (if error
      (res/bad-request error)
      (res/ok {:validated-values values}))))

;; (略)

ここまでかけたら再評価するかREPLを再起動して (go) した後でリクエストを投げて確認してみます。

$ curl -X GET -i 'http://localhost:8000/api/account?search=abc'
HTTP/1.1 200 OK
Content-Type: application/json;charset=utf-8
Content-Length: 36
Server: Jetty(10.0.9)

{"validatedValues":{"search":"abc"}}

$ curl -X GET -i 'http://localhost:8000/api/account?search=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab'
HTTP/1.1 400 Bad Request
Content-Type: application/json;charset=utf-8
Content-Length: 55
Server: Jetty(10.0.9)

{"search":"255文字以内で入力してください"}

$ curl -X POST -i -H "Content-Type: application/json" -d '{"name" : "トヨクモ" , "email" : "foo@example.com"}' 'http://localhost:8000/api/account'
HTTP/1.1 200 OK
Content-Type: application/json;charset=utf-8
Content-Length: 69
Server: Jetty(10.0.9)

{"validatedValues":{"name":"トヨクモ","email":"foo@example.com"}}

$ curl -X POST -i -H "Content-Type: application/json" -d '{"name" : "" , "email" : "foo@example"}' 'http://localhost:8000/api/account'
HTTP/1.1 400 Bad Request
Content-Type: application/json;charset=utf-8
Content-Length: 103
Server: Jetty(10.0.9)

{"name":"必須入力項目です","email":"メールアドレスの形式が正しくありません"}

おわりに

ここまででリクエストパラメーターの制御をAPIサーバーに追加することができました。 これで十分RESTful APIサーバーとして機能を追加していくことができる準備が整いました。

コードはGitHubのリポジトリに上げてあります。 12_RESTfulAPIを追加するというタグからご覧ください。

次は開発環境にデータベースを追加して、DBを使った開発をしていくための準備します。


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

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