Don't Repeat Yourself

Don't Repeat Yourself (DRY) is a principle of software development aimed at reducing repetition of all kinds. -- wikipedia

『ソフトウェアアーキテクチャ・ハードパーツ』

『ソフトウェアアーキテクチャ・ハードパーツ』を訳者の方からご恵贈いただきました。ありがとうございます。献本については基本的にすべて書評を書こうと思っているため、今回も記事にします。発売は10/27のようです。

おことわり

まず指示語についてです。記事中で「本書」「この本」と書く場合は『ソフトウェアアーキテクチャ・ハードパーツ』を指します。また、「著者」は本書を執筆した人を指すものとします。「筆者」といった場合、それは私のことです。

いわゆるスキミングをした状態で一旦書評をするため、本書の細かい議論の見落としや用語の誤認識が含まれる可能性があります。この書評はあくまで「どういった本か」「他の書籍と比較してどういった新規性がありそうか」などを紹介することを目的としています。逆に細かい議論の是非についての議論は目的とはしていません。

概要

本書はソフトウェアのアーキテクチャを考える際に避けては通れない、適切なアーキテクチャを選定するために必要になる「選択肢を提供」する一冊です。具体的にはマイクロサービスをどう分割するかといった話題や、どうコンポーネントを再利用できるようにするか、トランザクションをどう考えていけばいいかといった話題について具体的にパターンを紹介しています。ただしそれらのパターンは「これがベストプラクティス」として一つに定められるほど単純なものではないため、トレードオフを考えつつ「選択するパーツ」として紹介されています。

また、最近話題になっていた『ソフトウェアアーキテクチャの基礎』(以降、「基礎」)を執筆した著者陣が書いたもう一冊の本でもあります。「基礎」はアーキテクトとしての姿勢や、それぞれのアーキテクチャの簡単な概要が中心でしたが、この本はより実践に近く方法論寄りです。「基礎」が「What」を扱うとすれば、本書は「How」を扱うといった関係性でしょうか。

普通は一冊の中で語られることが多いであろう What と How が、なぜ分冊になったのかという疑問が浮かぶかもしれませんがこれには理由があるようです。ソフトウェアアーキテクチャにおける方法論は現実のシステムにナイーブに適用するのは難しく、こうした込み入った話はいわゆる原則を扱いたかった「基礎」では扱えなかった話題という事情があったためのようです。本書の冒頭でまず下記のように説明されています。

Neal と Mark は前著『ソフトウェアアーキテクチャの基礎』(オライリー・ジャパン)を執筆していた際に、内容を取り上げるのを諦めざるを得ない、込み入ったアーキテクチャの例に何度も遭遇した。そうした例は、簡単なソリューションを持たない、厄介なトレードオフの塊だった。二人はそうした例を「ハードパーツ」と名づけ、脇に避けていった。

現代ではデータをどのように設計し、分割しつつ整合性を保って保管しておくかといった一連の流れの重要度が増しています。この問題についても本書は拾い上げるよう努力しています。[*1]従来のアーキテクチャの議論では、マイクロサービスはどう分割するかとか、コードの関心事がどうこうとかそういったアプリケーションに限った範囲が中心だったように私は思っています。が、そうではなくデータをどう分割、配置、保管していくかといった問題についても議論に含めるようにしています。本書は「Modern Trade-Off Analyses for Distributed Architectures(”現代の”分散アーキテクチャのためのトレードオフ分析)」という副題になっていますが[*2]、そうした意味においてまさに現代版に刷新された新しいアーキテクチャ方法論ではないかと私は思いました。

構成

目次だけを拾い上げると下記のような構成になっています。

  1. 「ベストプラクティス」がないとどうなる?
  2. ソフトウェアアーキテクチャにおける結合の見つけ方
  3. アーキテクチャのモジュール化
  4. アーキテクチャの分解
  5. コンポーネントベース分解パターン
  6. 業務データの分解
  7. サービスの粒度
  8. 再利用パターン
  9. データの所有権と分散トランザクション
  10. 分散データアクセス
  11. 分散ワークフローの管理
  12. トランザクショナルサーガ
  13. コントラクト
  14. 分散データの管理
  15. 独自トレードオフ分析を構築する

2〜7章はいわゆるマイクロサービスの分解に関する章です。そして8〜14章はマイクロサービス間をどのように繋ぎ合わせていくか、そのプラクティスが紹介される章です。最後の15章では、実際にアーキテクトが自分自身のトレードオフ分析を本書のように作り上げる手法について簡単に解説されています。

私がとくに熱心に読んで印象に残ったのは5章と7章でした。本書の雰囲気を感じ取ってもらえるように、簡単に内容を引用しておきたいと思います。

5章ではモノリシックなアプリケーションをマイクロサービスに分解していくまでに、どういった手順を踏んでいけばよいかについての例が示されています。こうした形で体系的にまとめられた例はあまり見たことがなく、私自身も仕事で活かしていきたいなと思いました。

手順は(英語版のもののスクリーンショットから引用していますが)下記の図のようにまとめられています。次のような手順を踏んでいくと、ぶれなくマイクロサービスに分類していけるはずだという手法が示されています。著者らはマイクロサービスへの移行について、「思いつきで移行していい結果を生むことはほとんどない」とp.128で語っていますが、まさにその通りで勘に頼らず明文化した手順で取り組むのは何事においても重要です。

コンポーネントベースの分解の流れと使い方(電子書籍版の原著より引用)

7章ではより細かい単位でサービスを分割する分析基準について扱われています。5章ではいわゆるドメインサービスの分割に関する粒度までが扱われていたのですが、7章では「粒度分解要因」と「粒度統合要因」の2つの観点から、もう少し小さくサービスを分解する分析基準について議論されます。

粒度分解要因については「サービスの範囲と機能」「コード変動率」「スケーラビリティとスループット」「耐障害性」「セキュリティ」「拡張性」の観点に着目し、分離できるポイントを探していくとよいとされています(各観点の詳細は本書をご覧ください)。マイクロサービスの分解については私は正直ドメイン駆動設計で言われるような「境界づけられたコンテキスト」単位での分割しか考えたことがなかったのですが、それでは粒度が大きすぎるケースが多々あるのもまた悩みでした。これらの着目点によってもう少し分解できるという新たな視点を得られたように思います。

粒度統合要因は分解の逆です。逆に小さくなりすぎて不便になったサービスを統合するための着目点について議論されます。「データベーストランザクション」「ワークフローとコレオグラフィ」「共有コード」「データ関係」の4つの観点に着目し、結合できるポイントを探すとよいとされています(各観点の詳細は本書をご覧ください)。私が知らないだけかもしれませんが、マイクロサービスはついつい分解の方法に焦点が当たりがちで、分解しすぎたものを結合する手法についてはこれまであまり解説されてこなかったように感じています。

それぞれの「要因」についてはただ手法が解説されるだけではなく、何を目的として行われるべきかも補足説明されているので、導入の際に手段が目的化することも避けられそうでよさそうに感じました。私も今の現場で、サービスの構成に関して個人的に思うところがあるので、この手法を議論の土台にして問題提起してみたいなと思いました。

新規性

概要時にある程度書ききってしまったようにも思いますが、私が思った本書の新規性を紹介しておきたいと思います。

まず全体に通ずる話として各プラクティスを「ベストプラクティス」ではなく「トレードオフ」として紹介しています。ベストプラクティスとして紹介されてしまうと「本当にこれがベストなの?たとえばXX(目的から外れたもの)の場合はどうなの?」をはじめとした本来の目的を忘れたコーナーケースを突く不毛な議論や「これがベストなのだから他を選択する必要はない」といった思考停止が発生してしまう可能性が考えられます。一方で「このプラクティスはトレードオフである」としておくことで、「このケースではこういうデメリットはあるが、それは問題なさそうなのでこれが使えそう」といった選択の考慮と余地が生まれることになります。これは結果的に建設的な議論になり得ると私は考えています。著者の「(そのシステムにとっての)最悪のアーキテクチャを選択しない」という信条ともマッチしています。

アプリケーション内におけるデータ周りの管理に関してかなり明文化されたプラクティスを紹介しています。たとえばマイクロサービス化の文脈において、デプロイの単位が分けられたデータベースの分割は切っても切り離せないという話は多くのソフトウェアエンジニアが認知するところと思います。しかし、分割時に具体的にどういう手法があって、それらにどういうメリットデメリットがあるか整理されている資料は、単に私の認識の範囲が狭いだけかもしれませんがあまり見たことがありません。システムの柔軟性担保やデプロイ容易性を目的としたマイクロサービス化のために「データベースをきちんと分割しましょう」はわかるのですが、「でも具体的にどうやって?」の部分が暗黙知だったように思っています。少なくとも私はそうでした。同様に現在多くのシステムで利用されているであろうサーガパターンについても、本書ではなんと8つもトレードオフの分析込みでプラクティスが紹介されています。

感想

最後に雑多な感想を記しておきます。

定義づけて不毛な議論を避ける

まず、暗黙的に意味を受容してしまいがちな用語の定義をきちんとしてから話題に入っていくのがよかったです。これは「基礎」のときから継続的に著者らが気をつけていることかもしれませんが。たとえば印象的だったのは「1.6」の用語集でした。下記の引用は「結合」という単語の定義です。これは「疎結合」などさまざまな場面で登場してくるような、ソフトウェアエンジニアリングの文脈では非常に一般的な用語ではあるのですが、改めて「本書での意味」をきちんと定義づけしてから議論がはじまっていきます。

2つのアーティファクト(サービスを含む)は、適切な機能を維持するのに一方の変更が他方の変更を必要とする場合、結合されている。

ソフトウェアアーキテクチャに関連する本の多くはこうした定義づけがなく、ボヤッとした状態で議論が進むように感じます。[*3]一部のディスカッションを見ていると、そのボヤッとした土台をもとに読者(識者?)が独自の解釈を生み出し、肝心のソフトウェアをどう作り上げるかの議論ではなく、用語の意味をどう解釈するかについての議論でなぜか盛り上がる印象があります。これについては解釈のできる限り余地をなくすよう、概念を導入する側が定義づけをきちんとするべきだと個人的には思っています。

用語の定義づけというのは、こうした自転車置き場の議論というか、「解釈学」の発生をある程度防ぐことができるため非常に重要です。とくに「結合」のように、もはやソフトウェアエンジニアの間で暗黙的に利用されている用語についてきちんと定義づけをし、適用範囲を閉じてから議論を始めるのは重要です。また、仮に既存の定義づけを利用するとしてもどの識者の学説や発言に依って議論を進めるかを明示するだけでも十分効果があると思います。

「最悪を避ける」ための「トレードオフ」 = 「その状況によりあったもの」を探す旅

「基礎」に引き続き「これが唯一の正しいベストプラクティス」として本書の中のパターンを紹介しないのもよかったなと思いました。トレードオフという言葉はまさに言い得て妙です。私も日常業務で経験がありますが、アーキテクチャの考案というのは「どちらかを立てるとどちらかが立たなくなる」選択の連続のゲームです。

一方で本書では「ケースバイケースだよね」とか、「唯一の絶対正しい解はこれだ。議論の余地なし」として思考停止して片付けず、「最悪でないもの」を探るべく対話を繰り返しながらトレードオフの選択をひとつひとつ決めていきます。アーキテクチャの議論をしていると、ついつい「こうしなければならない」といった思考に陥りがちですが、実はとった選択肢にも何かしらの不味い点があることが多いです。たとえば思いつく単純な例ですと、レイヤードアーキテクチャを選択して各レイヤー間の依存を分けておくと、一部に変更が生じたとしても影響範囲が狭まるというメリットがあるかと思います。一方で、影響範囲を抑えるために多くのデータ変換を行う必要が出てくるわけですが、このデータ変換のメンテナンスコストが意外とかさみ、結果開発を遅らせることがある…などでしょうか。

少し話が逸れてしまいますが、現代は「ケースバイケース」の時代ではあると思います。(私は学生の頃哲学が専攻でしたので少しこの話を掘っておくと、)これを哲学の用語で相対主義と言ったりします。[*4]ただこのケースバイケース、「ケースバイケースである」としてそれ以上の議論を避ける傾向にあります。「ケースバイケース」が一つの結論になってしまうからです。しかしこの結論では議論によって合意をし、何かひとつ選ぶという行為を放棄してしまうため、そもそも何かが新しく判明したりはしません。仕事で言えば、何度ミーティングを開いたとしてもいつまでも物事が決まらず前に進んでいないのです。

私たちはアーキテクチャを考える際、相対主義に取り憑かれることなく、著者らのいう「最悪でないもの」、つまり裏を返せば「より適切」を見つける旅に出る必要があります。歴史が証明してきた通り唯一の正解というものはこの世界にはありませんが、その状況に合ったより適切な選択は合意によって導き出せます。その議論の際に「トレードオフ」の形で本書のプラクティスをひとつひとつ見つめ、その中から一つを吟味して選びとるのはとても有意義な結果を我々にもたらすはずです。

合意を取り物事を決める様は、本書の「Syspos Squad」の例を通じて一部知ることができますが、私も日々の開発の現場でこうした対話をできるようになりたいなと思わされました。本書を読み終えた後の次なる課題は「チーム全体にこのようなディスカッションをできる土壌をどう育てていくか」だったりするわけですが、それはまた別の解を探すことにしましょう。

*1:同じくオライリーの『データ指向アプリケーションデザイン』という本を読んでそれを熱心に考えた方も多かったはずです。

*2:邦訳時になぜか Modern の部分がまるっと落とされているのが気になりましたが。

*3:DDD の青い本とか。解釈するのは一つの享楽ではあるのですが、ただそれは何か別の学問のように感じます。そもそも読みが複数生まれる本は、少なくとも工学的な書籍においてはあまりいいものとは思えません。というのも、よく人文系学問で言われるような「読みの余地」は工学の目標ではないはずだからです。

*4:相対主義も哲学の分野によって微妙に定義づけが異なりそうなので補足ですが、私が念頭に置いているのは倫理学で一般に言われる相対主義の方です。『現代倫理学入門』(加藤尚武)や、あるいは『「みんな違ってみんないい」のか?』(山口裕之)などが念頭にあります。

26. Remove Duplicates from Sorted Array

ソート済みの、複数数字が重複する配列の中には一体何個の数字が含まれているかを考える問題。制約として、in-place で解く必要があります。たとえば、[1, 1, 2] という入力がされた場合、返り値は2で [1, 2, _] という順序の配列を返す必要があるということです。

leetcode.com

ただ、いまいち問題の意図がつかめず苦戦しました。単純に国語力の試される問題でもあります。実装は単純でした。

方針としては、

  • i と i+1 (i は配列のインデックスとする) 、要するに隣同士の数字を比較し、右の値が左の値より大きければ入れ替えるという作業を繰り返します。
  • 入れ替える作業が発生した場合、それは実質異なる数値を入れ替えることになるので、カウンターを1つ足します。

という作業を繰り返すだけで解くことができます。問題の意図が理解できれば捻りはとくになく素直な問題ではあります。

class Solution:
    def removeDuplicates(self, nums: List[int]) -> int:
        k = 0
        for i in range(len(nums)):
            if nums[k] < nums[i]:
                k += 1
                nums[k] = nums[i]
        return k + 1

時間計算量は O(n) でしょうか。与えられる配列の長さが増えると、その分だけループが多く回ることになります。

空間計算量は in-place な操作をしていることもあり O(1) になります。

20. Valid Parentheses

LeetCodeにあった問題です。()のように一組の括弧がすべての組み合わせに対して正しい順序で成立していれば正解。({[]})のような組み合わせはOKですが、({)}のような組み合わせはダメ、というものです。ちなみに初見では解けませんでした。Easyで結構初見で解けない問題があるのですが、本当にEasyなのかとても気になってくる。

leetcode.com

最初の自分の考察

(という文字列を見つけたとき、隣に)が来ていれば正当(valid)なのではと考えていたのですが、そうすると({})のような組み合わせの際に撃沈します。

解き方

この手の問題ではスタックが利用できます。開始の括弧、つまり({[が来た際にスタックに積んでおき、閉じ括弧が来たらスタックからpopして対応する括弧であればvalidとするという解法です。

たとえば、おそらく一番複雑な部類になるであろう ({[]}) について考察してみると、

  1. ( が来る→スタックに積む。スタックの状態: (
  2. {が来る→スタックに積む。スタックの状態: {(
  3. [が来る→スタックに積む。スタックの状態: [{(
  4. ]が来る→スタックからpop。popした値は[][に対応するので、valid
  5. }が来る→スタックからpop。popした値は{}{に対応するので、valid
  6. )が来る→スタックからpop。popした値は()(に対応するので、valid

といった感じで、綺麗に問題を解けます。最初解法を見たときおもしろいなと思いました。

下記に Python による解法を示しておきます。

# (, {, [ のどれかだった場合("開始"とする)、スタックに積んでおく。
# "開始"でなかった場合、スタックに積んだものを順に上からpopしていき、一致していなかったらFalseを返す。

class Solution:
    def isValid(self, s: str) -> bool:
        stack = []
        
        for char in s:
            if char in ["(", "{", "["]:
                stack.append(char)
            else:
                if not stack:
                    return False
                
                curr = stack.pop()
                
                if curr == "(":
                    if char != ")":
                        return False
                if curr == "{":
                    if char != "}":
                        return False
                if curr == "[":
                    if char != "]":
                        return False
                
        if stack:
            return False
        
        return True

時間計算量は O(n) です。for char in s というループ一つですべてが完結するためです。s の長さだけループが必要な回数が増えるだけ。

空間計算量は O(n) です。これはスタックを実質外部のメモリとして使用するためでしょう。s の長さだけ積まれるスタックの量が増えるので、この計算量になります。

Goでプロジェクトを始めたい際に楽できるツールを作った

数年ぶりに戻ってきたGoですが、環境が大きく様変わりしていて劇的に使いやすくなっていました。

とくにいいなと思ったのが go mod でした。これは Go の 1.11 に実験で入ったあとから利用できる機能のようです。

ところで、go mod init というコマンドがあります。これは Go プロジェクトを新規に始める際に、go.mod という設定ファイルを作成してくれるものです。Go はプロジェクトの内容をここから色々読み取って機能するようになっています。

このコマンドは、一度ディレクトリを作成してからそのディレクトリに入って叩く必要があります。たとえば、github.com/yuk1ty/startgo というパッケージパスでプロジェクトを始めたい際には、一度 startgo というディレクトリを作成し、cd し、その中で go mod init を叩く必要があります。

mkdir startgo
cd startgo
go mod init github.com/yuk1ty/startgo

さらに、git を使えるようにしたい場合、git init も中で打つ必要があります。加えて .gitignore も用意したいことも多いでしょう。Hello, world のために main.go もファイルを作成してエディタで用意して…といった具合にです。

git init
gibo dump go >> .gitignore
touch main.go
vim main.go
# vim で Go を編集する

最初 go mod init を触った際、Rust の cargo new のように一通りプロジェクトに必要な内容物を一気に生成してくれるといろいろ手間が省けて嬉しいなと感じたのを覚えています。git も gitignore も Hello, world 用の簡単なファイルも用意された状態でプロジェクトが始まり、go run main.go するだけで開発をスタートできると嬉しいはずです。

というわけで cargo にインスパイアされてこのニーズを満たす CLI ツールを作ってみました。

github.com

この手のツールはすでに存在していそうですが、とくに調べずにとりあえず好きに作ってみました。知り合いの Gopher に聞いてみたところとくに心当たりがなさそうでしたので[*1、この手のツールは本当に現時点ではないのかもしれません。心当たりのいる Gopher の方がいたら教えていただきたいです。

使い方と仕様

前提

  • v0.2.0 時点では Windows は動作するか保証できていないです。
  • VCS には git を想定しています。

コマンド

readygo コマンドには次のオプションが用意されています。

  • --module-path (-p): go mod init する際に使用するモジュールパス。
  • --dir-name (-n): 作成するディレクトリに使用する名前。省略可能。省略した場合は、--module-path を参考にディレクトリ名は設定される。
  • --layout (-l): Go Standard Layout か、そうではなく空っぽのディレクトリを作成するかを選べる。defaultstandard を設定可能。省略可能。省略した場合の値は default

たとえば、次のように使用することができます。

readygo --module-path github.com/yuk1ty/startgo --dir-name startgo --layout default

この結果生成されるファイル等は次のようになります。

ls -a --tree --level 1 startgo
startgo
├── .git
├── .gitignore
├── go.mod
└── main.go

もちろん短いコマンドも用意されています。

readygo -p github.com/yuk1ty/startgo -n startgo -s default

また、--dir-name オプションは省略可能です。

readygo --module-path github.com/yuk1ty/startgo

この場合、--dir-name には startgo が自動で挿入されます。これは非常にシンプルなロジックで決定されています。具体的には、スラッシュで一度 split した後に、配列の一番うしろを取るという簡単なロジックです。だいたいのケースはこれで対応できるのではないかと思い採用しています。

最後に --layoutstandard を設定すると、いわゆる Standard Go Project Layout のうち、cmd, pkg, internal の3つのディレクトリを一旦生成します。この Standard Go Project Layout はいろいろと議論の余地があるようですが[*2]OSS をいくつか眺めていると意外と利用のユースケースが見受けられたので、最小限用意するようにしています。

readygo --module-path github.com/yuk1ty/startgo --layout standard

この結果生成されるファイル等は次のようになります。

ls -a --tree --level 1 startgo
startgo
├── .git
├── .gitignore
├── cmd
├── go.mod
├── internal
├── main.go
└── pkg

なお、この --layout には所定のフォーマットの YAML ファイルを読み込む機能を導入しようかと考えています。ご自身のよく作られるパッケージの型に合わせてカスタマイズできるとよいのではないかとは考えています。他にも Go のコミュニティでよく利用されるディレクトリレイアウトなどあればご教授ください。一番多いのはフラットディレクトリではないかと思っているので、基本的には default の生成する空のディレクトリで事足りるのではないかとは思っています。

生成されるファイル

readygo コマンドを実行すると、go.mod 以外にもいくつか開発に必要なファイルを用意します。

コマンド実行後用意されるのは、具体的には次のファイルやディレクトリです。パッケージ作成後すぐに git にコミットしたり、あるいは go run して動作確認できることを目指してこのファイルやディレクトリを選んでいます。

  • .git: git init した結果生成されるディレクトリ。
  • .gitignore: Go パッケージで使用できる .gitignore が生成される。
  • main.go: Hello, world できるコードが記述された Go ファイル。

内部実装

内部実装はだいたい300行前後の比較的簡単なロジックになっています。Go で CLI ツールを作ったのは初めてでしたので、知見を少し残しておきたいと思います。

Cobra

Go で CLI ツールを使う際に使えるライブラリのようです。

github.com

とくに cobra-cli が強力で、この CLI ツールにいろいろと指示を出すと雛形を用意してくれます。この上で開発をすれば好みの CLI ツールを作成可能なので、非常に開発しやすく体験がよかったです。

今回作成した CLI ツールもこの Cobra を使い倒しています。コマンド処理の本体実装は root.go に記述されています。このディレクトリのレイアウトなどは cobra-cli の生成するものに従っています。

github.com

git や go コマンド周りの実装

git については最初 git 専用のライブラリがありそうだったので使用しようかと思いましたが、結局普通にシェルを Go から実行することにしました。これが一般的な方法なのかはよくわかりませんが、やることは git init くらいでその結果をアプリケーション側で利用することはなかったため、この形で間に合ったかなと思っています。git コマンドがお使いの環境にない場合はエラーになりそこで処理が終了します。

   git := exec.Command("git", "init")
    err = git.Run()
    if err != nil {
        return err
    }

github.com

ただ、cargo などの実装を見ていると VCS にはさまざまな種類を選択できるようです。一旦自分が使いたいために git での初期化のみに対応しましたが、今後他の VCS 対応も追加していこうかなと思っています。cargo では git 以外の VCS を使用する場合は追加でオプションをつけることで専用の初期化が走るように作られていますが、readygo もこの方式に習って新しいオプションをつけるようにしようかと考えています。

go xxx コマンドを Go から実行する際に特別なパッケージがあるかもよくわからなかったので、やはり同様に go mod init コマンドを Go から直接コマンドを実行しています。同様にとくにコマンドを実行した結果を使用したかったわけではなく、副作用だけ発生させて結果はそのまま破棄で構わなかったので、この形で間に合ったかなと思います。

   cmd := exec.Command("go", "mod", "init", *pkgName)
    err = cmd.Run()
    if err != nil {
        return err
    }

readygo -p [パッケージパス] ではじめられるので、ぜひ試してみてください。

今後のプラン

*1:というか、たった数コマンドなのでそこまで手間じゃないというのはありそうです。

*2:Russ Cox がコメントしている(https://github.com/golang-standards/project-layout/issues/117)。そもそも Go がオフィシャルに推進しているものではないことや、「多くの Go のエコシステムで使われてきた」という言説自体が誤りであること、また pkg ディレクトリがとくに余計な複雑性を持ち込むことになりよくないなどといった話が書かれている。

GitHub Actions 上の cargo install でインストールされるプラグインをキャッシュしつつ使いたい

GitHub Actions 上で cargo install 経由でインストールされるプラグインを使いたいと思いました。しかし実際に使ってみると、そのプラグインのインストールとビルドに5分程度毎回時間を要することがわかりました。これはビルド時間を伸ばすことにつながり開発の生産性を下げるのので、解決策を探しました。

背景

今回解決したい事象の背景としては次のとおりです。

  • GitHub Actions 上で、SQL クエリビルダー sqlx のクライアント sqlx-cli を実行し、ビルド時とテスト時にデータベースにマイグレーションをかけたい。
    • sqlx はコンパイル時にデータベースに接続し、データベース上にテーブルがあるかどうかまでチェックする機能がある。それに必要。
  • ただ、sqlx-cli はインストールとビルドに時間がかかる。手元の GitHub Actions ではだいたい5分くらいを要していたようだった。
  • キャッシュすれば解決するのでキャッシュしたい。

解決策

結果的に便利な action が見つかったので、それを使用することにしました。ただこの action は、基本的には cargo install 時のキャッシュ専用のディレクトリを作ってそこにキャッシュを行うだけという比較的単純なものなので、自分で同様の action を作ってみてもよいかもしれません。

github.com

今回は sqlx に対して使用したいので、使えるように下記のように定義を書き足しました。

# GitHub Actions の定義ファイルの一部
      - uses: baptiste0928/cargo-install@v1
        name: Install sqlx-cli
        with:
          crate: sqlx-cli
# 続く

最初のビルドではフルインストールとビルドが走りますが、2度目以降のビルドではきちんとキャッシュされていることを確認できます。

f:id:yuk1tyd:20220417163419p:plain
キャッシュが効くと、「Restored crate from cache」と出る

「ちょい使い」に便利なIOクレート ezio

Rust を使っているとどうしても思い出しながらでないと書けないものに標準入力、ファイルの読み書きがあります。というのも、Web アプリケーションを作るソフトウェアエンジニア(私)の場合日常業務でそこまで必要になる操作ではなく、ファイルの読み書きは S3 の SDK を叩くことのほうが多いですし、ターミナルからの入力はほぼ使うことはないためです。

しかし、ちょっとした CLI ツールを作るとなると話は変わってきます。これらはもちろんものによりますが、比較的プリミティブな標準入力操作やファイル読み書きの操作を求められるためです。そういった操作の際、Rust は Python ほどは楽に書けないというか、気をつけなければならないポイントがいくつかあり「手軽」とは呼べないことがあります。

こうした「手軽さ」のなさは面倒なポイントのひとつではありましたが、先日 ezio というクレートを作者の方が Twitter で紹介しているのを見て使ってみたところ、これが本当に使いやすくおすすめしたいと思いました。というわけで、ezio を軽く紹介する記事です。

ezio

Easy IO の略でしょうね。easy(/ˈiːzi/) + io→ez + io→ezioでしょうか。

github.com

docs.rs

作者いわく「エラーハンドリングや速度面はこだわっていないので、本番で使うには少し適さないかもしれない」とのことです。詳しくベンチマークを取ったわけではありませんが、もしかすると本番のアプリケーションのユースケースで使用が適さない場合があることに注意が必要かもしれません。しかし、手元で軽く動かす程度の CLI ツールを作る分にはこれで十分です。

特徴的なのは簡単なインタフェースで、プレリュードを一度読み込んでおくだけで、

use ezio::prelude::*;

たとえば標準入力はたったの1行で済みます。

let _ = stdio::read_line();

それでは実際に「Rust の標準ライブラリで書いたコード」と「ezio で書き直したコード」を比較しながら、どう便利になっていくのかを見てみましょう。

英和辞書ツールを作る

今回は先日発売された『手を動かして考えればよくわかる 高効率言語 Rust 書きかた・作りかた』より題材をお借りしております。実際の書籍の内容より少し改良を加えたものを使用しています。

英単語を入力すると日本語での意味を検索できる簡易的なアプリケーションを作ってみましょう。処理としては、

  1. 標準入力操作を待ち、単語をユーザーに入力させる。
  2. 裏で辞書ファイルを読み、メモリに保持させる。
  3. 1行1行一致検索を行う。

です。

Rust の標準ライブラリで

これを Rust の標準ライブラリでやると結構大変です。もちろん本番のアプリケーションではこのくらい注意深く実装する必要はあります。

標準入力は std::io::stdin().read_line() のように書くと行うことができます。普段 Rust の標準入力を使わない私は今回躓いたのですが、read_line すると末尾に改行コードがついてくるようです。そこで、改行コードを削る実装を追加しています。

ファイルの読み込みは、std::fs::Filestd::io::BufReaderstd::io::BufRead の3つの助けが必要になります。ファイルを読み込んで、それを1行1行読み込むという操作を行っています。BufReader に乗せてバッファリングし、その中身を順繰りにイテレートして一致検索を行います。

use std::fs::File;
use std::io::{stdin, BufRead, BufReader};

fn main() {
    // 1. 標準入力操作
    let mut word = String::new();
    stdin().read_line(&mut word).unwrap();
    if !word.is_empty() {
        // 末尾に改行コードがくっついてるので削る
        word.truncate(word.len() - 1);
    }

    // 2. ファイル読み込み
    let file = File::open("ejdict-hand-utf8.txt").unwrap();
    let reader = BufReader::new(file);

    // 3. 一致検索
    for line in reader.lines() {
        let line = line.unwrap();
        if line.find(&word).is_none() {
            continue;
        }
        println!("{}", line);
    }
}

同じディレクトリ内に下記のようにファイルを用意します。ファイル自体はこのサイトからダウンロードして置いています。

❯ ls --tree .
.
├── Cargo.lock
├── Cargo.toml
├── ejdict-hand-utf8.txt
├── src
...

動かすと単語の和訳の検索ができます。

❯ cargo run -q
homebrew
homebrew        自家醸造ビール

これを ezio で書き直すとどうなるでしょうか。見てみましょう。

ezio で

まず、Cargo.toml に下記を追加しましょう。

[dependencies]
ezio = "0.1.2"

ezio で書いたコードは下記のようになります。

use ezio::prelude::*;

fn main() {
    // 1. 標準入力操作
    let word = stdio::read_line();
    // 2. ファイル読み込み & 3. 一致検索
    for line in file::reader("ejdict-hand-utf8.txt") {
        if line.find(&word).is_none() {
            continue;
        }
        println!("{}", line);
    }
}

嘘みたいに短くなったことがわかります。標準入力の read_line 関数は String を返すので、わざわざ先ほどの標準ライブラリの例のように空のミュータブルな String を用意してそれを渡すといった処理は不要になります。

また、File::open とバッファリングを reader という関数で一挙に行っています。実際、reader 関数は下記のような実装になっています。

pub fn reader(path: impl AsRef<Path>) -> Reader {
    Reader(std::io::BufReader::new(
        std::fs::File::open(path).expect("Couldn't open file"),
    ))
}

このクレートは先ほども説明したとおり細かいエラーハンドリング等は行わず、内部で expect を使って Result 型は実質 unwrap されていることが多いので注意が必要ですが、簡易的な CLI ツールを作る分にはむしろこれで十分ではないかと思います。

今回は紹介しませんでしたがクレートの README に書かれているように、ファイルへの書き出しは file::write 等のシンプルな関数で済ませられます。

まとめ

  • ezio を使うと素早く標準入出力を含む操作ができる。

『コンセプトから理解するRust』

『コンセプトから理解するRust』を一足お先に読みました。Rust に関する日本語書籍の発刊が増えてきており、読むより発売するペースが上がっている気がします。私もだんだん精読するより積むほうが増えてきてしまいました。

今回も例のごとく、全体に目を通した上で感想などを書いていきたいと思います。本自体は2/12発売のようです。

本書はアプリケーションを実装しながら Rust を学んでいくというより、Rust に登場する特有の概念を解説しながら Rust を学んでいくというコンセプトになっています。その過程で他のプログラミング言語の実装と比較しながら、他の言語と Rust で違う点を説明したり、あるいは共通しているポイントを見つけたりしながら Rust 特有の概念を理解していきます。特有の概念というのは、代表的なものは本書の表紙でも紹介されている「所有権、型、トレイト」です。たしかにこれらを押さえれば、一旦 Rust は書き始められるかと思います。

『実践Rustプログラミング入門』をはじめとしたアプリケーションを実装しながらプログラミング言語を解説する本ですと、どうしてもサンプルコードを多く掲載する関係で本が分厚くなりがちです。しかし本書は類書と比較してもポイントをしっかり押さえつつもかなり薄めに仕上がっており、「他のプログラミング言語はそれなりに使用してきた*1が、Rust 特有の話だけサクッと学びたい」という方はこの本がよいのかもしれません。

最初にまず変数にまつわる話題を取り扱ったあと、その際登場する所有権について解説されます。その後に、Rust に登場する代表的な型について紹介されます。その中で Option や Result、Box や Rc をはじめとするスマートポインタも解説されます。スマートポインタには概念図が記載されており、理解しやすく書かれていると感じました。そうした話題を一通り押さえたあとに、Rust での抽象化の話題(トレイトやジェネリクス)が扱われます。その他の話題として、ファイルの入出力や関数型プログラミングの側面を利用した Rust プログラミング、並列処理・並行処理、あるいは非同期処理に関する話題、そして C との FFI が扱われます。

本書の目次はこちらのサイトに掲載されていますので、興味がある章があるようでしたら読んでみるとよいかもしれません。

本書の良さ

  • Rust 特有の概念を丁寧に説明している。
  • とくに、メモリ管理にまつわる部分は図でわかりやすく説明している。
  • 他の言語をそれなりにやってきた方は楽しめるかも。

Rust 特有の概念を丁寧に説明している

「所有権」や「ライフタイム」あるいは「トレイト」など、最初他の言語から Rust に入門すると理解に苦労する概念をとくに丁寧に説明しています。あとがきにもありましたが、もともと著者の方もこうした Rust 特有の概念の理解に苦労したうちの一人で、どのように説明すればわかりやすく伝えられるかを考えて本書を書かれたとのことでした。あるいは、変数の代入と束縛については微妙に違いがある話など、他の入門書では見ない話題が度々書かれていました。

トレイトとジェネリクスの説明に1章丸々割いているのは本書の大きな特徴だと思います。Rust ではこの2つの概念を用いて抽象化をゼロコストで行えるという特徴がありますが、これらを懇切丁寧に解説していると思います。dyn Traitimpl Trait の話題、ならびに動的ディスパッチと静的ディスパッチにまつわる話題が解説されます。

とくに、メモリ管理にまつわる部分は図でわかりやすく説明している

Rust 特有の概念として所有権をはじめとするメモリ管理の概念があります。それを理解するのは最初の関門となるわけですが、本書では図を使って直感的に理解できる説明を心がけているようです。要所要所で図によるメモリ管理や処理の遷移がよく解説されており、ひとまず雰囲気を掴むのにはこれで十分だと感じました。

他の言語をそれなりにやってきた方は楽しめるかも

この本では C/C++Python との比較がまま登場するため、他のプログラミング言語をいくつかよく触ったことがあり、プログラミング言語間の思想や目的の違いを理解されている方にはとくに楽しめる内容になっていそうだと思いました。

ただ本書はあくまで Rust への入門書ですので、「所有権のチェックはそもそもコンパイラ内部ではどのように扱われているか」「トレイトと実際の実装の紐付けがコンパイラ内部ではどのように扱われているか」といったような言語処理系が好きな方が興味を持つであろう話題には触れられてはいません。そうした高度な話題をタイトルから期待されている方には少し向かないかもしれません。

Rust 入門時に必要な知識がコンパクトに得られる一冊ではないかと思います。「作りたいものは決まっていて、すばやくキャッチアップしたいが手頃な資料がないだろうか?」と考えていた方にはおすすめできるかもしれません。

*1:それなり、の定義は難しいですが…