Web Application & Software Architecture 101 ~④アベイラビリティとは?~

educativeのWeb Application & Software Architecture 101という講座を受けたのでその内容についてまとめです.これは現在のWebアプリケーションがどのような設計で成り立っているかを概念レベル(コードレベルではない)で説明されてい講座です.なのでWebアプリケーションの設計について学びはじめの方やWebアプリケーションの設計の全体像を知りたいという方にぴったりの講座です. 第4回目の今回はアベイラビリティについてです.これはシステムがどのぐらいの割合通常通り使用できるかを表す指標です.24時間のうち24時間きっちりと動くシステムのアベイラビリティは100%という感じです.このアベイラビリティは航空システムや金融システムにおいてはほぼ100%ではないと行けいないとても重要な指標です.今回はなぜシステム障害が起きてアベイラビリティが下がってしまうかやその対策方法の概要について説明します.

システム障害が起きる理由は?

システム障害が起きてアベイラビリティが下がってしまう主な原因は以下の4つです.

  • ソフトウェア故障

  • ハードウェア障害

  • ヒューマンエラー

  • 計画的なダウンタイム

ソフトウェア障害はOSがクラッシュしてしまったり無応答のソフトウェアがメモリを大量に消費してしまってサーバ全体が停止してしまうことを指します.ハードウェア障害はハードディスクが駄目になってしまったりなどハードの故障によって起きる障害です.ヒューマンエラーは人が設定を間違えてサービスを起動してしまったりすることによる障害です.この講座ではこのエラーが一番多いと書いていました.計画的なダウンタイムはOSのアップデートなどで意図的にシステムを止めることを指します. このような障害を乗り越えてどうやったら,ハイアベイラビリティなシステムを作れるのかを次に説明します.

ハイアベイラビリティなシステムを実現するには?

フェイルソフト

1つ目のアプローチはフェイルソフトです.これは1つ1つの機能を実現するアプリケーションを別々のプログタムで作っておき1機能のアプリケーションがダウンしても他に影響のないようにしておく方法です.例えばSNSでは画像の投稿やいいねができなくなっても文字の投稿と閲覧はできるようにして,サービス全体として動いている状態を作ることができます. この別々のプログラムで作っておくというのはマイクロサービスという考え方です.マイクロサービスで作られたSNSでは例えば文字の投稿やいいねの管理,画像アップロードやログイン機能をそれぞれ独立したサービスとしてられており,それぞれのサービスが通信することでサービス全体(SNS)として機能します.

冗長性

2つ目のアプローチは冗長性をシステムに持たせると言った方法です.これは同じ機能のサービスを複数用意しておいて,どれかが故障しても大丈夫なようにしておく方法です. 冗長性の持たせ方にも2種類あります. 1つ目がActive-Active HA(High Availability Mode)でこれは実際にユーザーがアクセスできるサービスを複数用意しておくという方法です.このうちのいくつかのサービスがダウンしても他のサービスでまかなえるので,サービス全体として利用でき状態を保つことができます.

f:id:harada-777:20200824210225p:plain:w300

2つ目がActive-Passive HA(High Availability Mode)はユーザーからアクセスされない本番と全く同じサービスを裏で待機させておくという方法です.この裏で待機させているサービスを待機系と呼んだりします.

f:id:harada-777:20200824210231p:plain:w300

アクティブになっているサービスに何か問題が発生したら,待機しているサービスと入れ替えることで問題なくサービスが利用できるようにすることができます.

Blue-Green Deployment

Blue-Green Deploymentは計画的なダウンタイムを限りなく0にするための方法です.これはActive-Passive HAのように2つの環境を用意します. サービスをアップデートする為にデプロイしたい時を考えます.例としてユーザが直接アクセスする本番環境をブルーとして,待機系の方をグリーンとします.ブルー環境のサービスを直接アップデートすると新しいサービスが終了してから再起動するまでダウンタウンタイムが発生しユーザーはサービスを利用できなくなってしまいます.そこでまずはグリーン環境をアップデートして正常に動くことを確認してからユーザーがグリーン環境に接続するようにルーティングを変更します.これがBlue-Green Deploymentと呼ばれる方法です.こうすることでデプロイによるダウンタイムをなくすことができます.

感想

僕が働いているユーザベースはマイクロサービスアーキテクチャを採用しているので上記の考え方に基づい冗長性のあるシステムになっており,自然とこの考え方は知っていたのですが改めて今回整理することができてよかったです.

Web Application & Software Architecture 101 ~③スケーラビリティとは?~

educativeのWeb Application & Software Architecture 101という講座を受けたのでその内容についてまとめです.これは現在のWebアプリケーションがどのような設計で成り立っているかを概念レベル(コードレベルではない)で説明されてい講座です.なのでWebアプリケーションの設計について学びはじめの方やWebアプリケーションの設計の全体像を知りたいという方にぴったりの講座です. 第3回目の今回はスケーラビリティについてです.昨今のWebアプリケーションはアクセス数がかなり増えているのでスケーラビリティを考えることは重要だと言えます.今回は改めてそのスケーラビリティとは何か?なぜ重要なのか?どうやってスケーラビリティを高めるのかの指針について紹介します.

スケーラビリティ(Scalability)とは?

スケーラビリティとは,処理が増えてもアプリケーションが遅延せずにその処理をできる能力のことです.たとえばある一人がWebアプリケーションにリクエストを行ったときレスポンスまでに0.5秒かかるとします.これが100万人になろうと0.5秒でレスポンスを返せるアプリケーションはスケーラビリティが高いこ言えます.スケーラビリティが低いアプリケーションだと10秒かかるようになってしまいます.つまり10秒の遅延が発生します. そしてなぜこのスケーラビリティが重要かと言いますと,もし遅延が発生してしまったらユーザーは他のWebページやサービスに飛んでいってしまって自分たちのサービスを使ってくれないからです!

遅延(Latency)

この遅延には2種類存在します.1つ目がネットワーク遅延で,2つ目がアプリケーション遅延です.

f:id:harada-777:20200815181207p:plain:w400

ネットワーク遅延はブラウザなどのリクエストがサーバー(アプリケーション)に届くまでと,レスポンスがサーバーからブラウザまで届く時間の合計です.ネットワーク遅延を削減するにはCDN(Content Delivery Network)と呼ばれる分散化されたキャッシュサーバーのようなものを用意するといったアプローチが取られたりします.これをユーザーの近く(地理的に)に置くことでネットワーク遅延を削減したり,本体のサーバーの負荷を減らしたりします. アプリケーション遅延はアプリケーションがレスポンスを返す為の処理をする時間です.こちらを削減する方法がいくつかあり,次節以降で解説します.

スケーラビリティを高めるアプローチ

ここからはアプリケーション遅延を改善し,スケーラビリティを高める方法について解説します.アプローチとしては主に2つあります.

Vertical Scaling

Vertical Scalingはアプリケーションを起動するサーバー(PC)のCPUやRAMを高めることでスケーラビリティを高める方法です. Vertical Scalingはアプリケーションコードを触わずにスケーラビリティが高められるのでまずはこの方法を試すのが良いとされています.しかしその1台のサーバーの能力を高めるだけでは効果は限定的にはなってしまいます. そこで登場するのがHorizontal Scalingです.

Horizontal Scaling

Horizontal Scalingはアプリケーションを起動するサーバーの数を増やしてスケーラビリティを高める方法です.1台では無理な場合は2台,3台と複数のサーバーでアプリケーションを起動してそれらでリクエストを捌くようにします.またcloud computingサービスを使うと動的にサーバーの数を増やしたり減らしたりしてくれるので金銭の節約にもなります.(Requestが増えたときのみサーバーの数を増やしてくれる) この方法ではロードバランサーを設置したり,サーバー間でデータの共有をどうするのか?などの問題が発生するのでVertical Scalingよりは複雑になってしまいます.

スケーラビリティへのアプローチとして本講座で述べられているのは社内からのアクセスや限定されたアクセスしか想定されないアプリケーションはVertical Scalingでの対応をし1サーバーでアプリケーションを立てるので十分であるとされていました.逆に一般公開される予定のものはHorizontal Scalingができるようなアーキテクチャ(例えば本講座で少し後に出てくるマイクロサービスアーキテクチャ)で設計しておくことが推奨されていました.

ボトルネック

ここでは本講座で述べらていたボトルネックになり得る主な箇所について述べさせて頂きます. - データベース - 1つのデータベースだけを使用しているとデータベースがボトルネックとなってしまいます.読み取り用のデータベース,書き込みのデータベースなど正しく分割することで高速化を期待できます. - アプリケーションアーキテクチャ - 非同期で実行した方が良い部分を非同期で実装できていない部分があるとここがボトルネックになることがあります. - キャッシュを不使用 - 正しくキャッシュを使うとデータベースへのアクセスや対象のアプリケーションから外部のアプリケーションに接続する必要がなくなってパフォーマンスを改善することができます. - データベースの選出方法 - トランザクションや強い整合性が必要ないのにRDB (Relational Datebase)を使っていませんか?NoSQLを使用すればHorizontal Scalingが可能なのでよりパフォーマンスが求められる場合はこちらを使うことが推奨されています.(DBについては本講座の後半にて解説)

実際の開発ではパフォーマンスが遅いと感じたときに以上の場所を中心に,実際の時間を測定しボトルネックを探していくのが良いと感じます.パフォーマンスを考慮したコードやアーキテクチャは複雑になりがちなので必要なときに,目標の数値が達成できるようするのが良いと思います.

感想

今回は主にスケーリングについてまとめました.これを読むことでどういう部分がボトルネックになりやすいのかということについて整理ができました.また実際の現場でどう計測するのかなどより具体的な部分についてより勉強してみたいと感じました.

Web Application & Software Architecture 101 ~②Web Architectureとは?~

educativeのWeb Application & Software Architecture 101という講座を受けたのでその内容についてまとめです.これは現在のWebアプリケーションがどのような設計で成り立っているかを概念レベル(コードレベルではない)で説明されてい講座です.なのでWebアプリケーションの設計について学びはじめの方やWebアプリケーションの設計の全体像を知りたいという方にぴったりの講座です. 第2回目の今回は層によるWeb Architectureの概要とそこで使われるClient Server Architecture,Http ProtocolやWeb Architectureが採用するRESTと呼ばれるアーキテクチャスタイルについてまとめます.

前回にも述べましたが,Webアーキテクチャユーザーインターフェース,バックエンドサーバー,データベース,キャッシュサーバーなど様々なコンポーネントから成り立っています.さらにそのアーキテクチャの構成を詳しく見ていくとClient -Server Architectureと呼ばれるアーキテクチャになっています.

Client-Server Architecture

Client-Server ArchitectureはWebアプリケーションのアーキテクチャの基本的な構造です.世の中のほとんどのWebアプリケーションはClient-Server型のアーキテクチャとなっています.

Client

Clientとはユーザーインターフェースを表示する部分を担う部分です.Client側はサーバー側にRequestを送ることでResponseを受け取ります.そのResponseの情報を元にJavascriptやHtml,CSSと呼ばれる言語を使ってClientはユーザーインターフェースを描画します.

f:id:harada-777:20200802195753p:plain:w300

ビジネスロジックがない薄いClientをThin Client,ビジネスロジックが含まれるClientをThick Clientと呼ぶそうです.

f:id:harada-777:20200802195749p:plain:w300

f:id:harada-777:20200802195744p:plain:w300

Server

ServerとはClientからRequestを受けとって必要な情報を返す部分です.全てのサービスはアプリケーションサーバーが起動している必要があります.アプリケーションサーバーとは,プログラムを実行させる場所のようなものです.JavaWebサービスを作る場合はTomcatやJettyと呼ばれるアプリケーションサーバーの上に自分で書いたプログラムを動かすいイメージです. このServerがHtmlなどを使って画面の生成までする場合をServer-Side Renderingと呼び,Client側が画面を生成する場合Client-Side Renderingと呼びます.本講座の後半にそれぞれのメリットデメリットが詳しく述べられるのですが,ざっくり言うとServer-Side Renderingでは重たいHtmlの生成などをServer側に持ってこれるメリットがあり,Client-Side Renderingをすると,その分Server側がシンプルになり,Server側のバックエンドアプリケーションを複数のClientで使い回すことができます. 次節以降はClientとServerがどうのように通信するかについて見ていきます.

ClientとServerの通信方法

Http Protocol

ClientとServerはHttp Protocolと言うプロトコルに従って通信が行われます.Http ProtocolはRequest-Response型の通信プロトコルである,ステートレスであることが特徴です.ClientはいくつかのHttpメソッド(Get,Post,Put,Deleteなど)を使ってがServerにRequestを行うことでデータを取得したり作成したりします.

REST API

さらに最近のWeb アプリケーションはRESTと呼ばれるアーキテクチャスタイルを実装しています.アーキテクチャスタイルとは設計原則のようなものです.この設計原則をHTTP Protocolなどを使って実装したAPIREST APIです.REST APIは以下のような特徴を持ちます. + Client-Server型である

  • ステートレスである

    • 1回1回の通信は独立している
  • リソースを操作する命令が予め定義され,共有されている

    • HTTPのGET,POST,PUT,DELETEメソッドなど
  • URIでリソースの場所を一意に識別できる

  • レスポンスとしてXMLもしくはJSONで操作結果を返す

このような原則を全てのWebアプリケーションが実装することで再利用性が高まります.例えば向いはクライアント毎(スマホ,PC)にサーバーのコードが別れていたそうですが,今ではビジネスロジック部分のバックエンドサーバーを使い回し,JSONでデータを受け取ってそれぞれのクライアントに合わせた画面が描写するといったことが可能になっています. 外部サービスにおいてもRESTを実装されていることが多いので,HTTPプロトコルで通信使,JSONを受け取るようにこちら側のAPIを作れば簡単に他のサービスの機能を扱うことができます.

f:id:harada-777:20200802195740p:plain:w300

HTTP Pull & Push

HTTP Protocolには実は2種類の通信方法があります.それがPull型とPush型です.それぞれ用途が違うので解説をしていきます.

Pull型

HTTP Pull型は必ず最初にClientからRequestがあるパターンの通信方法です.ほとんどの通信はこの方式でHTTPのデフォルトモードはPull型です. Pull型にも場合によって2種類あります. 1つ目が手動でボタンなどをクリックしてRequestを送信して,Responseを受け取る方法です. 2つ目が自動でデータの更新などによって動的に画面を描写するときに使用される方法です.これは自動でスポーツサイトやニュースサイトの画面が更新されるイメージです. この2つ目についてもう少し詳しく見ていきます.これをPull型で実現するにはAjaxと呼ばれる技術を使います. AjaxとはAsynchronous Java Script & XMLの略で,JavascriptXMLを用いて非同期通信を実現する技術のことです.この技術が生まれる前まではRequestを送ってからResponseが返って来るまでプログラムは動かすことができませんでした.例を用いて説明すると,同期通信の場合Google Mapで拡大や縮小をするとき(このタイミング裏ではより詳しい情報を取得するためのRequestが送られる),プログラムが止まり.毎回画面が真っ白になって画面が見れないタイミングが発生するイメージです.現在Google Mapがこうなっていないのは非同期通信が行われているからです.非同期通信はRequestを送っている間も,プログラムは止まらず,ブラウザは通常通り操作でき,Responseが返ってきたタイミングで画面が描写されます. 2つ目のPull型での自動更新パターンはこのAjaxを使って一定の間隔でRequestを非同期で送り,Responseを受け取ったタイミングで画面を描写し直すといったことを行います.

f:id:harada-777:20200802195737p:plain:w300

ただこの方法には2つ問題があります.1つ目の問題が一定間隔後しかデータが更新されない,2つ目の問題はデータの更新がないのにRequestを送ってしまい無駄に通信が発生してしまいます.この2点を解消する,またよりリアルタイムでデータを取得する必要性が発生し,Push型の通信がいくつか生まれました.

Push型

ここからはPush型の方法をいくつか紹介します.

Ajax Long Polling

1つ目がAjax Long Pollingです.これは厳密にはPush型では擬似的にPush機構が実現できる為本講座ではPush型に分類されていました(おそらく..).この方法はまずClient側からRequestを送った後,データの更新があるまでコネクションを保ちます.(コネクションとは通信経路のようなものでそこが開いていないと通信ができません.この方法は意図的に長くこの経路を開きます,)そしてデータの更新があったタイミングでServer側からResponseを受けとり,画面を更新します.そしれResponseを受け取った時にまたClient側はRequestを送ります.

f:id:harada-777:20200802195733p:plain:w300

この方法を使うことでPull型のときよりデータ更新から画面の描画までのタイムラグをなくすことができ,またClientからのRequestする回数も減るので通信の削減にもなります.

Web Socket

Web Socketは双方向で通信が可能な技術です.TCPという層(HTTPより下位の層)での通信方法であり,低遅延でもあります.ざっくりとして流れは以下の通りです. f:id:harada-777:20200802200409p:plain:w300

まずClient側はハンドシェイクと呼ばれるHTTP通信を行い,その後サーバー側がハンドシェイクを受け取るとWebSocketへと切り替えTCP層の通信が始まります. 双方向かつ低遅延の通信ができるのでチャットアプリケーションやリアルタイムマルチプレイヤーゲームなどに使用されます.

HTML5 Event Source

次に紹介するのがHTML5 Event Sourceです.これはServer側からClient側への単一方向の通信方式です.ServerからEventという形でデータが送られてきます.Ajax Long PollingのようにClientからRequestを送る必要がなく,通信の削減ができます.またHTTPプロトコルを利用するのでWebSocketより気軽に導入できます. これはTwitterなどリアルタイムでタイムラインを更新したいSNSアプリケーションに使用されます.

Streaming Over HTTP

Streaming Over HTTPは容量の大きいファイルを分割してストリーミングすることができる技術です.ストリーミングがインターネット上でダウンロードせず動画や音声を視聴できる技術です.ざっくりとしたイメージは以下の通りです.

f:id:harada-777:20200802195729p:plain:w400

大きいサイズのメディアが分割されて運ばれて,Client側で再生されます.(この過程で様々なデータに変換されますが,まだ細かい部分は理解できておりません..)

まとめ

僕自身はあまりPush型の通信方法を知らず知らないことが多い回でした.この回を受けたことで実装方法はわからなくても,作りたいものが出た時にどういう技術を調べればいいかという指針ができたのでよかったです.(全体像を知るのは大事ですね..)

Web Application & Software Architecture 101 ~ ①N層のソフトウェア アーキテクチャ

今回はeducativeのWeb Application & Software Architecture 101という講座を受けたのでその内容についてまとめていきます.これは現在のWebアプリケーションがどのような設計で成り立っているかを概念レベル(コードレベルではない)で説明されてい講座です.なのでWebアプリケーションの設計について学びはじめの方やWebアプリケーションの設計の全体像を知りたいという方にぴったりの講座です. 第1回目の今回は層によるソフトウェアアーキテクチャの分類を紹介します.現在のソフトウェア 特にWebアプリケーションは多重の層のような構成になっています.そもそもここで指す層とは何か?なぜ多重の層のような構成になったのか?も含めて解説していきます.

層(Tier)とは?

アプリケーションによる層とは,アプリケーションやサービスのコンポーネントの論理的な分離のことです.コンポーネントとはデータベースやバックエンドアプリケーションサーバー,キャッシュサーバー,ユーザーインターフェースなどを指します.このようなコード内ではなく物理的な分離のようなイメージです. 本講座ではコード内の分離をLayerと表現し明確に区別していました.Clean Architectureの層はLayerでありTierではないようです(今回のブログの層はTierを指します). ここからはなぜ1層のアプリケーションから複数のN層アーキテククチャになって行ったかを含めそれぞれについて説明します.

1層のアーキテクチャ

1層のアーキテクチャは1つのマシンに全てのコンポーネント(ユーザインターフェース,ビジネスロジック,データベース)が含まれるアーキテクチャです.以下のようなイメージです.

f:id:harada-777:20200727203834p:plain:w500

1層のアプリケーションにはそれぞれ以下のようなメリットデメリットがあります.

メリット
  • 通信する必要がない
    • セキュリティーの安全性が高い
    • ネットワークによる遅延がない
デメリット
  • パフォーマンスがマシンに依存する
  • UIがマシンに依存する
  • ユーザーがアップデートしない限りアップデートされない

通信をする必要がないというのがやはり大きなメリットです.しかしその分アプリケーションを起動しているマシンの性能への依存度が高くなってしまいます..

2層のアーキテクチャ

2層のアプリケーションはクライアントとサーバー(データベース)からなるアプリーケーションです.このアプリケーションではクライアンど側のアプリケーションにロジックがあり,データの保存や呼び出しを外側のサーバー(DB)を呼び出して行います.イメージは以下の通りです. なぜ1層のアプリケーションではなく2層にする必要があったのでしょうか?それには以下の理由が挙げられます. - 複数のデバイスとデータを共有できる - 大量のデータをクライアントに保存しなくて済む

f:id:harada-777:20200727203839p:plain:w500

複数のデバイスとタスクを共有すると言ったニーズやゲームアプリなどの大量のデータの保存を必要したいというニーズに答える為に2層のアプリケーションが生まれたようです.2層のアプリケーションでは最低限の通信であり,そこまで遅延を気にしなくて済みます.しかしセキュリティ面を気にする必要があり,あまり重要でないデータを扱うアプリでの使用を推奨されていました(タスク管理,ゲームアプリなど).

3層のアーキテクチャ

3層のアプリケーションはユーザーインターフェース,バックエンドサーバー,データベースの3コンポーネントからなるアーキテクチャです.バックエンドサーバーにロジックが移りユーザーインターフェースのアプリケーションがシンプルになったイメージです.

f:id:harada-777:20200727203844p:plain:w600

ブログなど現状のWebアプリケーションの多くがこの3層アーキテクチャで実装されています.

N層のアーキテクチャ

N層のアーキテクチャはここでは3層より多くの層から成るアーキテクチャを指します.このN層アーキテクチャはFacbookやInstagramAirbnbなど大規模なアプリケーションに採用されています. N層アプリケーションを構成するコンポーネントには以下のようなものがあります. - ユーザーインターフェース - バックエンドサーバー - キャッシュサーバー - ロードバランサー - リクエストを複数のサーバーに分散させる役割を持つ - 検索用サーバー

f:id:harada-777:20200727203849p:plain:w600

なぜこのように多階層にする必要があったか?それはよりアプリケーションを機能拡張に柔軟な状態にしたかったからです.そしてこれを実現するための原則として単一責任の原則とうものがあり,N層アーキテクチャは複数のに層することでこれを守ろうとしています. 単一責任の原則とはあるコンポーネントには1つの役割だけを与えるという原則です.言い換えると1つのコンポーネントを変更する理由は1つだけにするようにコンポーネントを作るということです.こうすることでコンポーネントの再利用性や拡張性が高まります. 例えばログイン機能を持つコンポーネントを分離しておくと,スマホアプリやWebアプリからの両方からそのログインコンポーネントを使えます.さらに他のデータの保存を担当するコンポーネントはログインに関して気にしなくて済みます.そしてもしログインに関することを修正したければこのログイン用のコンポーネントを修正すればいいとすぐにわかります. このように1つのコンポーネントに1つの役割だげを持たせる構成にする為にN層のアーキテクチャが採用されるようになりました. ただN層のアーキテクチャは通信が大量に発生するのでネットワークの遅延や,セキュリティの問題など他に考慮すべき点は発生します.

感想

今の世の中だと複数の層から成るアプリケーションが割と普通になっており,なぜ1層じゃダメなんだっけ?と考えたことがなかったので今回その背景をより詳しく理解することができました.また少ない層はそれはそれでメリットがあるということを改めて認識することができました.

アートオブアジャイルデベロップメントを読んで

今回は「アートオブアジャイルデベロップメント」と言う本のまとめを書きたいと思います.この本はアジャイル開発と呼ばれる方法の1つであるXP (eXtreme Programming)についてかなり詳しく書いた本です. 僕自身が所属するユーザベースもXPをかなり忠実に再現しています.とはいえ僕はまだ入って3ヶ月の未熟者なのでXPについて勉強する為にこの本を読みました(正確に言うとCTOの方からお勧めして頂きました!). 読んだ上でどんな人が読むべきかと言いますと

  • アジャイルについて詳しく知りたい

  • 実際に導入する中でこういうときどうしたらいいの?という困った点がある

人に向いていると思います.いきなりの初心者すぎますと情報量に圧倒されます..読み方はまずは全体をサーって読んで後から気になるところを詳しく読むという使い方がいいと思います.

まずはそもそもアジャイルって何の為にあるの?ってとこからまとめていきたいと思います.

なぜアジャイルなのか?

アジャイルにおける成功

アジャイル開発とは何か?アジャイルとはある3つの成功を同時に達成するための理念です.その3つの成功を以下に示します.

  • 個人的な成功

    • 個人的なやりがい
  • 技術的な成功

    • エレガントな設計のプログラム
  • 組織的な成功

    • 組織に価値をもたらすこと

プログラマはコードを書くことにやりがいを感じ,開発チームはエレガントな設計をしたいと思う.ただし,組織に価値を持たらなさければソフトウェアに意味はありません.組織に価値をもたらすとはお金を出してくれるユーザに価値が届くことを指しています.アジャイルはこれら3つの成功を同時に達成したいと思う場合に適用すべきだと本書には書かれています. この成功を定義した背景には以前の開発手法での成功が「納期に予算内で仕様通りのソフトウェアが完成する」ことであったからです.これは本当に成功なのか?と言うことで生まれたのが前述の3つの成功とアジャイルという概念だと思います.

アジャイルマニフェスト

そしてアジャイルにおける成功をするにはどうすればいいのか?その規範を示したのがアジャイルマニフェストである.以下にその一部を抜粋します.

  • プロセスやツールよりも個人と対話を

  • 包括的なドキュメントよりも動くソフトウェアを

  • 契約交渉よりも顧客との協調を

  • 計画に従うことよりも変化への対応を

これらにはプロセスを守ることが正しいのか?本当に届ける価値は何なのか?顧客と自分達は的なのか?,計画は変わるものであると言う思いが込められていると感じます.そしてこれらをより具体化したものにアジャイル原則があります.こちらは割愛しますがこのマニフェストに従い原則を守ることで3つの成功を実現させようと言うのがアジャイルと言う考えです.

XP

アジャイル開発のある1つの手法がXPで,本書でとりあげられているものです.1-20名のチームに適用可能であると記されています.XPは様々なプラクティスで構成されており,これらを実践することで成功へ近づけます. 次節以降でいくつかとりあげて解説しようと思います.

XPのプラクティス

ここからはXPのプラクティスについて説明します.ここで紹介するプラクティスは全て3つの成功を満たすのに繋がっています.さらにお互いに関連があったり,あるプラクティスが前提で成り立つものがあります.この本がプラクティスは全て導入せよと主張しているのはその為です.そしてそう言う視点で本書を読むと理解が深まります. ただその前に登場する役割について説明します.

役割

以下本書に載っている役割を抜粋して紹介します.

  • 顧客

    • チームが作るソフトウェアを定義する人

    • プロダクトに関する全ての情報を持つべき人

  • プロダクトマネージャ

    • プロダクトのビジョンを維持し,推進する人

    • 市場を良く理解し何が最も重要かを見極めることができる人

  • ドメインエキスパート

    • 特定の業界の専門知識を持つ人
  • プログラマ

    • 開発者
  • テスター

    • ソフトウェアの品質に責任を持つ人
  • プロダクトコーチ

    • XPのプラクティスに精通している人
  • プロジェクトマネージャ

    • チームが組織の他のメンバーと仕事をする手助けをする人
  • ステークホルダー

    • エンドユーザや経営陣を指す

この役割を1人兼務することがあります.プロジェクトマネージャやドメインエキスパートが顧客の役割も果たすと言った具合です.

次節以降で具体的にプラクティスについて説明します.

体制に関するプラクティス

活き活きとした仕事場

より生産的に仕事をする為に以下のことを気を付けるべきだと記述されています.

  • 定時に帰るようにしよう

  • 休憩をしっかりととろう

無理に仕事を進めるとバグの原因になってしまって結果的に価値を届けるのが遅くなってしまう. またプロダクトのビジョン(後ほど説明する)を開発者に伝えるようにする.これはプロダクトマネージャの仕事です.そうすることで開発者はやりがいを感じることができまgす.これは個人の成功にもつがります.

情報満載の仕事場

部屋に入るだけでプロジェクトの様子がわかる部屋づくりをしましょう.ホワイトボードに大きく進捗状況を表すチャート(バーダウン・バーンアップなど)を用意しましょう.XPでは変に電子化すべきではないとしています.電子化してしまうとこのように大きく貼り出すことができません.このような仕事場と作ることでいち早く問題に気付き解決することができます.

全員同席

全員同席はXPで最も重要なプラクティスです.これは前述の役割の人が全員同じ場所で仕事をすると言うプラクティスです.開発者全員はもちろんのこと顧客やプロジェクトマネージャもです!大変ですがXPの様々なプラクティスは顧客が同席していると言う前提のプラクティスが複数あります.情報満載の仕事場も皆がバラバラだと実現できません.特に後ほど出てくるプラクティスは顧客が同じ場所にいることが大切です.本当の顧客が同席できなければ顧客と同等の情報量を持つ人を開発者と同席させる必要があります.

なぜここまでやるのか?と言いますと背景にはXPでは開発速度の制約(ボトルネック)を開発者としています.つまり開発者の生産性を高めることが直接開発速度向上に繋がると考えています.開発者の生産性を向上させるには必要な情報を正しく素早く開発者に伝えることが一番であるとこの本では主張しています.よって顧客をも同席させいつでも情報を尋ねることができる状況を作り出しているのです.

信頼

お互いを信頼しあってチームで仕事をすることで,生産性は急上昇し素晴らしいことをすることができます.(XPはマニフェストにもある通り個人と対話を大切にし,顧客との強調も大切にしています) 先ほど述べた全員同席顧客はプログラムの間の信頼を気付く為に最も効果的です!お互いが熱心に仕事をしている姿を見ることができます.

計画に関するプラクティス

ビジョン

ここで言うビジョンとはプロダクトビジョンを示す.プロダクトビジョンとは,プロダクトの方向性を示すものです.作ろうとするソフトウェアやサービスの核です. そしてこのビジョンを明確にしたビジョンステーてメントを書くことが推奨されています.ビジョンステートメントは以下の3つを満たす言葉で書くべきだとされています.

  • 何を達成すべきか

  • 何故価値があるのか

  • 達成の基準は何か

このビジョンをチームに伝えるのがビジョナリーの仕事で,これはプロダクトマネージャがやるべきことです.このビジョンを情報満載の仕事場に記すことでチームメンバーのやりがいにもつながります. そして後に続くリリース計画もこのビジョンに沿って決まっていきます.

リリース計画

リリース計画とはどの順番で顧客に価値を届けていくかの計画である.XPではリリース計画期間の中にイテレーションと呼ばれる期間が複数あります.イテレーションについては後述します. XPでは早期に頻繁にリリースすることを目指しています.それは何故か?と言うのを以下に示します.

  • 収益が高くなる

    • 大量の機能を一気にリリースするより,価値ある機能を絞ってリリースする方が最終的な収益が高くなります.同じ機能を2年かけて実装するとき,2年後にリリースする場合は2年間収益が0ですが,3ヶ月ごとにリリースすると3ヶ月目には収益が発生します!
  • フィードバックが増える

    • 頻繁にリリースするとユーザからのフィードバックを細かくもらう事ができます!これは本当の価値を生み出すのに貴重な情報です.また頻繁にリリースすることでXPのチーへのユーザからの信頼度が高まります.

頻繁にリリースするメリットはわかったもののどうやって実現すればいいのでしょうか?このためのプラクティスはいくつも存在するのですが主には以下の方法をとります. - 最小顧客価値に絞る

- XPでは期間を定めて機能を絞ることをします.そして本当に重要な昨日からリリースするので常に最も価値かる状態でリリースすることができます.
  • 常にリリースする状態にしておく

    • 機能を縦割りに開発することでこれを可能にします.例えば全UIが完成したらバックエンドのAPIやDBを開発して..ではなくユーザーのログインのためのUIとバックエンドのAPIとDBを作ると言った具合です.

このリリース計画ではまず,ビジョンを明確にし,次のリリースに向けて最小顧客価値を定義します.そしてその機能を実現するストーリーを作成します.そのストーリーを見積もり,チームのベロシティ(開発速度を示す指標)と照らし合わせてリリース計画を作成します(計画ゲーム).

ストーリー

ストーリーとは顧客価値を表します.例としては「ユーザはツイートを作成できる」などです.この1文がストーリーです.エンジニアが作ることが多いのですが注意としては顧客の言葉で書くことです.何故かと言いますと,次に紹介する計画ゲームで顧客がどのストーリーが価値があるかを判断するためです!エンジニアの〜テーブルを作るって言葉は顧客的には?ってなってしまい価値の優先順位をつけることができません. 実際にプログラマが行うタスクはエンジニアリングタスクと呼びます.複数のエンジニアリングタスクが集まってストーリが構成されるイメージです. プログラマはリリース計画を立てるためにこのストーリーにどのぐらいかかるかを見積もる必要があります.

見積もり

ストーリーをどうやって見積もればいいかを以下に示します.またこのストーリーを見積もるときの単位としてストーリーポイントと言うのを使います.1ストーリーポイントは1日プログラマが邪魔されえずに仕事をした際に消化できるタスクの量です.

  • ストーリーを実現するエンジニアリングタスクを考える

    • 慣れると直感的に見積もれるらしい..
  • 他のストーリーポイントとの相対ポイントで見積もる

    • 似たような仕様の者は同じポイント

この見積もりは1ストーリー1分以内で行うことが推奨される!もし時間がかかってしまう場合はストーリーを見積もるための仕様への理解が足りないか,技術的な情報が足りないということです.仕様への理解が足りない場合は顧客に情報を求めましょう(ここでも全員同席が前提である).技術的な理解が足りない場合はスパイクストーリーという技術調査のためのストーリーを作りましょう.時間制限をつけて見積もるのに必要な情報をこのスパイクストーリーで集めます.

計画ゲーム

計画ゲームとはストーリの優先順位を決める作業です.開発者は開発コストに関する情報を持っており,顧客はソフトウェアの価値に関する情報を持っています.この2人が協力することでリリースの優先順位を決めていきます.

イテレーション計画

イテレーションとは1-2週間の区切りを指します.以下のような流れで各イテレーションは進んでいきます. 1. 前イテレーションのデモをする 2. 前イテレーションを振り返る. 3. ベロシティーを元にイテレーションで消化するストーリーを決める 4. コミットメントをする 5. 開発をする 6. リリースを準備する

振り返りやデモはXPにおける重要なプラクティスです.振り返ることでプロセスを改善し,デモをすることで顧客の信頼をえることができます.

ベロシティー

ベロシティーとは1イテレーションにおけるストーリー消化ポイント数です.この値を使うことで大体の開発の進捗を見積もることができます.ベロシティが15のチームは150ポイント分のストーリーを消化するのに10イテレーションかかると言った具合です(本書ではリスクを考慮した方法も載っている.). 何故この方法がうまくいくかと言いますと,開発者の見積もりは当たらないにしても一貫してずれているというデータがあるそうです(10週間で終わる予定が12週間,5週間で終わる予定のものが6週間といった常に20%遅れるなど).なので実際の1イテレーションにおけるストーリー消化ポイントであるベロシティを使うとある程度正確に見積もれるという訳です. 注意点としはベロシティーが安定するには3-4イテレーションかかります.

開発に関するプラクティス

インクリメンタルな要件

XPでは事前に要件定義が記されたドキュメントを用意しません.そうすることのメリットは以下の通りです.

  • 顧客が要件定義するのと同時に開発することができる

  • 要件定義をインクリメンタルに行うので変化に対応しやすい

ただしこれを実践するには前提条件があります.それは顧客が開発者と同席しておりストーリーに現れない要件をいつでも聴ける状態にあることです.このようの状況でないと開発者は要件がわからないときかなりの時間ロスをしてしまいます.またドキュメントを残さないので仕様が残る場所が普通ではありません.XPではテスト駆動開発を行いテストに仕様を表します.よって全員同席とテスト駆動開発ができてインクリメンタルな要件が実現可能なのです.

ペアプログラミング

ペアプログラミングとは,2人で1つのプログラムを開発する手法です.メリットとしては以下の2つです.

  • フローになれる

    • 割り込みに対して,片方のペアが応答すれば良いので,作業を中断させずに済む
  • 妥協をしなくなる

ペアプログラミングは2人交代で以下の役割を行います.

  • ナビゲーター

    • 全体を考える人
  • ドライバー

    • コードを書く人

ドライバーがコードを書いている間ナビゲーターはそれを見ながら全体の設計やテストの抜け漏れを考えます.そうすることでよりいい設計(シンプルな設計)を目指すことができます.

テスト駆動開発

テスト駆動開発はXPの要の1です.XPが頻繁にリリースする為には設計がシンプルである必要があります(シンプルな設計に関しては次少し触れます).テスト駆動開発は設計をシンプルに保つことに多いに貢献します.ざっくり言いますとメソッドのインターフェースから考えることができる,テストがあるので安心してリファクタリングできるなどです.またXPがドキュメントを用意せずに済むのもテスト駆動開発のおかげです.以前も解説した記事があるのでこちらをご参照ください.

シンプルな設計

シンプルな設計とは?設計をシンプルに保つことでソフトウェアは開発速度を落とすことなく,どんどん変更が簡単になるはずであり,逆にこういうことが可能な設計はシンプルであるということです(僕も完全に理解はできていません..).この本では以下の要素を満たす者がシンプルであるとされています.

  • You Aren't Gonna Need it

    • 将来を予測して無理に抽象化,共通化しない
  • Once and Only Once

    • 重複は避ける
  • 自己ドキュメント化コード

    • チームが読みやすいコードを書く(綺麗さではない)
  • サードパーティコンポーネントを分離する

    • 外部の呼び出しは同じような変換を何度も書くことになるので,アダプターパターンなどを利用しよう.
  • 公開済みインターフェースを制限する

シンプルな設計をすることでインクリメンタルに要件を拡大できますし頻繁にリリースもできます.

リリースに関するプラクティス

完全Done

完全Doneとはプロダクトコードレベルでストーリーが終わったことを指します.つまりテストも揃ってバグもなくリファクタリングも終わった状態です.XPでは完全Doneのものしかリリースしません.無理にベロシティを上げる為にDoneにするのはやってはいけないことです.顧客に無理な期待を抱かせたり,ベロシティが不安定になったりいいことがありません.

継続的インテグレーション

コードを数時間ごとにインテグレーションしようというプラクティスです.そうすることで,バグの早期発見や,バグを探す範囲を限定できます(数時間前は正常に動いていたコードにマージをしたので数時間前と今の差を比べれば良い).そして常にリリースできる状態になっておりリリースの難易度を下げることができます.そうすることで頻繁にリリースし,顧客により価値を届けることができます!

感想

正直のこの本は中々のボリューム感と情報量でした..ただ何周か見返すとXPの凄さを感じれる本です.様々なプラクティスが関連しながら3つ成功へ繋がっているというのが段々と見えてきました(まだまだですが..).その中でやはり思うのが全員同席とテスト駆動開発の重要性です.XPは顧客までもがチームとして同席することで情報の共有や信頼関係を産み出し,その上で様々なプラクティスが成り立っています.そして技術的な成功や価値を頻繁に届ける為にテスト駆動開発が存在しています.頻繁に届けるやインクリメンタルな要件はテスト駆動開発なしでは難しいでしょう..また時間をおいて読み返したいと思える本でした.

Junit5とMockk

今回はJunit5とMockkを使ってテストを書く際のコツについてまとめたいと思います!こちらを参考したのでお時間があるとき是非見て頂きたいです.

テストをメソッドやカテゴリ別に分ける方法

みなさんよくテストをクラス毎に作成をしませんか?僕は良くそうしています..その時にクラスにたくさんのメソッドが生えているときにフラットでテストを作成してしまうと,どのメソッドがどのテストかわからなくなってしまいます. 例えば以下のような計算用のクラスがあるとします.

class Calculator {
    fun add(v1: Double, v2: Double) = v1.plus(v2)
    fun multiply(v1: Double, v2: Double) = v1.times(v2)
}

普通にテストを書いていくと以下のようにフラットになりどれがaddのテストかどうかだんだんとわかりづらくなってきます..(今回はメソッドもテストも少ないですがこれが膨らんでいくことを想像してください)

internal class CalculatorTestNotInner {
    private val calculator = Calculator()

    @Test
    fun `足し算ができる`() {
        assertEquals(3.0, calculator.add(1.0, 2.0))
    }

    @Test
    fun `引数の順番を入れかえても足し算の結果は同じである`() {
        assertEquals(3.0, calculator.add(1.0, 2.0))
        assertEquals(3.0, calculator.add(2.0, 1.0))
    }

    @Test
    fun `掛け算ができる`() {
        assertEquals(3.0, calculator.add(1.0, 2.0))
    }
}

これをメソッド毎に分ける方法が@Nestedというアノテーションです.使い方は以下のようになります.

internal class CalculatorTest {
    private val calculator = Calculator()

    @Nested
    inner class Add {
        @Test
        fun `足し算ができる`() {
            assertEquals(3.0, calculator.add(1.0, 2.0))
        }
        @Test
        fun `引数の順番を入れかえても足し算の結果は同じである`() {
            assertEquals(3.0, calculator.add(1.0, 2.0))
            assertEquals(3.0, calculator.add(2.0, 1.0))
        }
    }

    @Nested
    inner class Multiply {
        @Test
        fun `掛け算ができる`() {
            assertEquals(3.0, calculator.add(1.0, 2.0))
        }
    }
}

こうすることでコードも見やすくなりますし,結果も階層構造でみることができるようになります! f:id:harada-777:20200628141902p:plain 是非活用してみてください.

オブジェクトを作成するヘルパー関数の導入

テストをする時にテスト用のオブジェクトを毎回作るのが大変って感じる時ありませんか?今回紹介するのはその手間を少し楽にしてくれる方法です! 例えば以下のようなユーザーとそれをDBなどに作成するユースケースがあるとします.

data class User(val firstName: String,
                val lastName: String,
                val old: Int,
                val profile: String)
                
class UserCreateUseCase {
    fun execute(user: User) {
       // Userを作成する
    }
}

このユースケースのテストとして以下のようなケースがあるとします.

internal class UserCreateUseCaseTestNoHelper {
    private val useCase = UserCreateUseCase()

    @Test
    fun `Userを作成することができる`() {
        val user = User("firstName", "lastName", 20, "test")
        assertDoesNotThrow {
            useCase.execute(user)
        }
    }

    @Test
    fun `ファーストネームが空文字のUserを作成することができない`() {
        val user = User("", "lastName", 20, "test")
        assertThrows<Throwable> {
            useCase.execute(user)
        }
    }


    @Test
    fun `ラストネームが空文字のUserを作成することができない`() {
        val user = User("firstName", "", 20, "test")
        assertThrows<Throwable> {
            useCase.execute(user)
        }
    }
}

何回もユーザーをインスタンス化するコードを書いてますね..ここで登場するのがヘルパー関数です.以下がその例です.

fun createUser(
    firstName: String = "firstName",
    lastName: String = "lastName",
    old: Int = 20,
    profile: String= "test"
) = User(firstName, lastName, old, profile

このようにデフォルト引数を用意したオブジェクトを生成する用の関数を用意しておきます(CreationUtil.ktとかに作成)! これを使うと以下のようにテストをかけます!

internal class UserCreateUseCaseTest {
    private val useCase = UserCreateUseCase()

    @Test
    fun `Userを作成することができる`() {
        assertDoesNotThrow {
            useCase.execute(createUser())
        }
    }

    @Test
    fun `ファーストネームが空文字のUserを作成することができない`() {
        assertThrows<Throwable> {
            useCase.execute(createUser(firstName = ""))
        }
    }

    @Test
    fun `ラストネームが空文字のUserを作成することができない`() {
        assertThrows<Throwable> {
            useCase.execute(createUser(lastName = ""))
        }
    }
}

だいぶコード量減らすことができますし,どの値をテストで見ようとしているかより明確になります.

モックを再利用する

モックの生成にはコストがかかってします.なので以下のように毎回テストの度生成するとテストにかかる時間が増えてしまいます.

internal class UsersGetUseCaseTestNoReuse {
    lateinit var userGateway: UserPort
    private val useCase = UsersGetUseCase(userGateway)

    @BeforeEach
    fun setUp() {
        userGateway = mockk()
    }

    @Test
    fun `ユーザーのリストを取得できる`() {
        every { userGateway.getUsers() } returns listOf(createUser(), createUser(firstName = "taro"))
    }
}

そこで登場するのがclearMocksという関数です.これにモックを渡すことでmockをリセットすることができます.

internal class UsersGetUseCaseTest {
    private val userGateway: UserPort = mockk()
    private val useCase = UsersGetUseCase(userGateway)

    @BeforeEach
    fun setUp() {
        clearMocks(userGateway)
    }

    @Test
    fun `ユーザーのリストを取得できる`() {
        every { userGateway.getUsers() } returns listOf(createUser(), createUser(firstName = "taro"))
    }
}

このようにモックはできるだけ再利用しましょう!

ラムダ式を受け取る拡張関数のモック

最後にラムダ式を受け取る拡張関数をどうモックするかについて説明する.これは僕がExposedというORマッパーをモックしてテストを行おうとした際にハマったのでご紹介させて頂きます. Exposedはラムダ式を受け取るtransactionという拡張関数のスコープ内でDao(データアクセスオブジェクト)を使用してテーブルからデータを取ってきます.またこのtransactionDataSourceから生成できるDatabaseオブジェクトを受けとり.こ実際にデータベースに接続ができないとエラーを起こしてします. このtransactionが呼ばれているかをveryfyしようとテストを書こうとすると,本来は接続が可能なデータベースに対するDatabaseオブジェクトを渡さないと行けません.今回はこれをMockすることでデータベースなしでテストが可能にする方法をお伝えします. 拡張関数のモックはクラス名がが必要となります.Exposedのtransactionをモックしたいときは以下のようになります.

    mockkStatic("org.jetbrains.exposed.sql.transactions.ThreadLocalTransactionManagerKt")

モックは以下のように解除できます.

        unmockkStatic("org.jetbrains.exposed.sql.transactions.ThreadLocalTransactionManagerKt")

次にtransactionのモックの定義を書きます.

every {
    transaction(statement = captureLambda<Transaction.() -> Any>(), db = any())
} answers {
    lambda<Transaction.() -> Any>().invoke(mockk())
}

captureLambdaラムダ式を受け取る関数のモックを作成することができます.今回はtransactionが受け取る関数の型に合わせています.さらにMcokkでは answersを使ってこのモックが呼ばれたとき何を実行するかを定義することができます.lambda<Transaction.() -> Any>()でモックに渡したラムダ式をキャプチャーし,実行させています.今回テストではtransaction内でdaoが実行されているかを検証するためにこのように書きました.(transactionとdaoの呼び出しをveryfyするテストを書きたかった) 実際Exposedのテストを書くと以下の感じになります.

internal class UserGatewayTest {
    private val dao: UserDao.Companion = mockk()
    private val gateway = UserGateway(dao, mockk())

    @BeforeEach
    fun setUp() {
        mockkStatic("org.jetbrains.exposed.sql.transactions.ThreadLocalTransactionManagerKt")
        every {
            transaction(statement = captureLambda<Transaction.() -> Any>(), db = any())
        } answers {
            lambda<Transaction.() -> Any>().invoke(mockk())
        }
        clearMocks(dao)
    }

    @AfterEach
    fun tearDown() {
        unmockkStatic("org.jetbrains.exposed.sql.transactions.ThreadLocalTransactionManagerKt")
    }

    @Test
    fun `ユーザーを取得することができる`() {
        val result = gateway.getUser("tanaka", "taro")
        verify {
            transaction(db = any()) { }
        }
        verify {
            dao.find("tanaka", "taro")
        }
    }

まとめ

今回は僕がKotlinでテストを書く際に覚えておいた方がいいコツなどをまとめてみました.他にもこうするといいよ!っていうのがあれば是非教えてください!! 今回のコードは以下に上げています.

https://github.com/Yoshiaki-Harada/junit5-and-mockk-blog

TDD (テスト駆動開発)まとめメモ

本投稿はテスト駆動開発の第3章(まとめの部分)を読み,UdemyのTDD講座受講したことから得た知見をまとめる目的で執筆します. 内容的にはTDDで開発する理由やメリット,注意点についてまとめようと思います.

TDDとは?

実際のコードよりも先に失敗するテストコードを書き,そのテストが通るように実装していく開発手法です.

なぜTDD?

なぜTDDで開発を行うと良いのかは主に2つ目的があります.

エラーの発生を少なすることができる

以下の図を見て下さい.左側がTDDではない場合のストレスとエラーの因果ループ図であり,右側がTDDで開発を行った場合のストレスとテストの因果ループ図です.左側はストレスが増加するとエラーが増加してしまう正の接続を表し,右側はストレスを感じた際にテストを実行することでストレスを下げるという負の接続を表します.

f:id:harada-777:20200602211658p:plain

TDDで開発行わない場合例えば,何かを変更したときや実装したときにそれは正しいのか?という疑問を抱ながらコードを書き続けなければなりません.これはストレスを感じたままコードを書き続けることとなり,するとエラーの発生が増加してしまいます.しかしTDDで開発をする場合は,ストレスを感じたらすぐにテストを実行でき何も壊していないことや意図通りの動作をしていることが確認できるので,ストレスを下げることができます.そうすることでエラーの発生を防ぐことができる.

設計を強制することができる

テスト駆動で開発をすると必ずテストを書くことになります.するとあるときテストを書こうとすると以下のような違和感を感じることがあります. + テストをする前の準備が長い + テストの実行時間が長い

こういう違和感は設計を見直すサインとなります.テストをする前の準備が長いのはテストに必要なオブジェクトの生成が大変なのが原因だったりします.これはオブジェクトを分解する時が来た時を教えてくれます.テストの実行時間が長いのはテスト対象の関数がやっていることが多すぎることを示唆してくれています.関数の責務を見直し分割することを検討してみましょう.

どうやってTDDを行う?

TDDは必ず以下の手順に従います. 1. レッド + まずは失敗するテストを先に書く 2. グリーン + テストを通す最小の実装を書く 3. リファクタリング + テストが通り続ける状態でコードの重複などの変更を行う

レッド

TDDではまずテストから書き始めます.このときのポイントとして必ず失敗することを確認します.そもそも実装がないので失敗するのは当たり前なのですが,アノテーションのつけ忘れ等でそもそもこのテストが呼ばれないことを防ぐ為に必ずこのことを確認します.

グリーン

テストが落ちることを確認した後にはテストを通す最小のコードを記述します.テストを通す最小のコードって何?って思われるかもしれませんがこれは値をベタ書きするということです. 例えば以下のように昨日を求める関数を実装したい場合はまず以下のようなテストに対して

assertEquals("2020-01-01", calculator.yesterday("2020-01-02"));

以下のように直接値を返す関数を実装するということです.

public String yesterday(String: date) {
    retrun "2020-01-01"
}

なぜこのようなことを行うかの理由は主に3つあります + テストのミスに気付ける + 具体例から徐々に一般化する方が考えやすい + グリーンとレッドでは精神状態が違う

特に2つ目のいきなり難しく一般化しようとせず徐々に一般化した方が,結果的に早く一般化されたコードに辿りつけるというのは開発あるあるだと思っています笑

テストを書くときの注意点

常にレッドバーから始める

前述した通りこれは呼び忘れ等を防ぐ為に重要なことです.

ロジックに対してテストを書く

テストとメソッドは常に一対一という訳ではありません.ロジックに対してテストがあるべきです.プライベートメソッドにテストを書きたくなる時は,設計を見直すタイミングです.これは何かをパブリックメソッドでは?と考える機会を奪ってしまいます.

いつでも動くテストを書く

ある特定な時間にしか動かないテストを書いてはいけません.思わぬ時に失敗するテストは思いもよらないところが影響を与えている可能性があるので設計やコードを見直しましょう.

十分な量のテストを書く

テストはどれぐらい書くべきか?という答えにテスト駆動開発は「不安が退屈になるまで」と答えています.つまり安心できるまでということです.もう少し具体的なこととしてUdemyの講座に出てきたテストを書くときのポイントを載せさせて頂きます. + ロジックはどうあるべきか + ロジックの反対のことはわかるか + エッジケースは確かめられているか + エラー条件を確かめられているか

感想

テスト駆動開発はxUnit部分などライブラリに関する部分やIDEのリファクタに関する部分など古い部分はあるものなぜそうするか?という部分が割と書かれている点でいい本だと感じました.一方で具体的な手法は新しい媒体で身に付ける方がいいと感じました.

KotlinでDIコンテナを作成する

先日僕が所属するユーザベースという会社でJavaでDIコンテナを作るというHands Onがありました.折角なのでそれを参考にKotlinでも実践してみました.DIコンテナはわかるけどどうやってんの?って人は自分で作ってみるとかなり理解が深まります.

DIコンテナとは?

アプリケーションに依存性(オブジェクト)を注入するためのフレームワークである.僕が昔執筆した記事にもう少し解説しているので是非読んでみて下さい.

基本機能の実装をする

DIコンテナに必要な基本機能は + 型を管理する + 型を指定してインスタンスを取り出せる の2つです. Kotlinのリフレクションという機能を使うのですがそれには以下のdependecyを追加する必要があるのでお願いします.

compile "org.jetbrains.kotlin:kotlin-reflect:1.3.72"

まず型を管理する為にMap<KClass<*>, KClass<*>>を作成します.KClassとはKotlinのクラス情報を保持するクラスです.クラス::classとすることで取り出すことができます.今回はこのようにKClassをキーにしてKClassを取り出せるマップで型情報を管理します.マップに登録をする為にregisterというメソッドも作成します.

object Container {
    private val map = HashMap<KClass<*>, KClass<*>>()
    public fun <T : Any> register(clazz: KClass<T>) {
        map[clazz] = clazz
    }
}

次に型を指定してインスタンスを取り出す機能を実装します.ここでリフレクションという機能を使います.リフレクションとは実行時にクラスのフィールドやメソッドの情報をしたり,実行することができる機能のことです.この機能を使うことで動的にコンストラクタを呼び出してインスタンスを得ることができます. 実際のコードは以下のようになります.

object Container {
        ・
        ・
        ・
    public fun <T : Any> get(clazz: KClass<T>): T {
        val constructor =
            map[clazz]?.primaryConstructor ?: throw Exception("${clazz.simpleName} is not found in this Container")
        return constructor.call() as T
    }
}

map[clazz]?.primaryConstructorでプライマリコンストラクタを取得し,constructor.call()でコンストラクタの呼び出しとなります. 指定した型情報がない場合は例外を吐くようにしています. usecaseを作成して実際にコンテナを登録してみます.

class SampleUsecase {
     fun execute() {
        println("execute usecase")
    }
}
main() {
    Container.register(SampleUsecase::class)
    val usecase = Container.get(SampleUsecase::class)
    usecase.execute()
}

execute usecaseが表示されコンテナからインスタンスを取得できることがわかります. しかしこのままではオブジェクトを保存して返してるだけのようなものなので,Interfaceでインスタンスを得られるようにします. 実際のコードは以下のようになります.

object Container {
        ・
        ・    
    public fun <T : Any, U : T> register(parent: KClass<T>, child: KClass<U>) {
        map[parent] = child
    }
}

親クラスをキーにと子クラスのクラス情報を登録します.こうすることで親クラスを指定して,子クラスのインスタンスを取り出すことができます. usecaseのインターフェースを用意して実際に試してみます.

interface ISampleUsecase{
    fun execute()
}

class SampleUsecase : ISampleUsecase {
    override fun execute() {
        println("execute driver")
    }
}
fun main() {
    Container.register(ISampleUsecase::class,SampleUsecase::class)
    val driver = Container.get(ISampleUsecase::class)
    driver.execute()
}

実行してみると正しく動作することがわかります.

再帰的にインスタンスを初期化できるようにする

次に複数のコンテナに型情報を登録して,取得したいインスタンスに依存するクラスのインスタンス再帰的に初期化できるようにします.やりたいことのイメージは以下の感じです.

f:id:harada-777:20200524221035p:plain:w300

getメソッドが以下のように変更され,コンストラクタの呼び出しを引数の分再帰的に実行しています.

public fun <T : Any> get(clazz: KClass<T>): T {
    val constructor = map[clazz]?.primaryConstructor ?: throw Exception("${clazz.simpleName} is not found")
    val params = constructor.parameters //constructorに必要な引数を取得する
        .map {
            val kClass = it.type.classifier as KClass<*> //引数の情報をKClassに変換
            map[kClass] ?: throw Exception("$kClass is not found in this Container")
        }
        .map { get(it) } //再帰的に引数もインスタンス化する
        .toTypedArray()
    return constructor.call(*params) as T //引数を渡してコンストラクタを呼び出す
    }

正しく動作するか確認してみましょう.その為に,usecaseがgatewayを呼び,gatewayがdriverを呼ぶようにします.

class SampleDriver {
    fun execute() {
        println("execute driver")
    }
}
class SampleGateway(private val driver: SampleDriver) {
    fun execute() {
        println("execute gateway")
        driver.execute()
    }
}
class SampleUsecase(private val gateway: SampleGateway):ISampleUsecase {
    override fun execute() {
        println("execute usecase")
        gateway.execute()
    }
}
fun main() {
    Container.register(SampleDriver::class)
    Container.register(SampleGateway::class)
    Container.register(ISampleUsecase::class, SampleUsecase::class)
    val usecase = Container.get<SampleUsecase>(SampleUsecase::class)
    usecase.execute()
}
fun main() {
    Container.register(SampleDriver::class)
    Container.register(SampleGateway::class)
    Container.register(ISampleUsecase::class, SampleUsecase::class)
    val usecase = Container.get<SampleUsecase>(SampleUsecase::class)
    usecase.execute()
}

実行すると以下のように出力され正しく初期化されていることがわかります.

execute usecase
execute gateway
execute driver

Singletonパターンを使ってインスタンスを使いまわせるようにする

最後に一度作ったインスタンスを再利用するSingletonパターンを導入します. 現状は毎回インスタンスが呼ばれてしまうので,Containerのgetを呼ぶ為に必要なインスタンスを生成します.まずはそのことを確認したいと思います. コンストラクタが呼ばれた時に出力を行うようにします.

class SampleDriver {
    init {
        println("call driver's constructor")
    }
    fun execute() {
        println("execute driver")
    }
}

以下のようにするとusecaseとgatewaygetでdriverは二回初期化されます.

fun main() {
    Container.register(SampleDriver::class)
    Container.register(SampleGateway::class)
    Container.register(ISampleUsecase::class, SampleUsecase::class)
    val usecase = Container.get(ISampleUsecase::class)
    usecase.execute()
    val gateway = Container.get(SampleGateway::class)
    gateway.execute()
}

実際実行すると以下のように出力されます.

call driver's constructor
execute usecase
execute gateway
execute driver
call driver's constructor
execute gateway
execute driver

これは毎回無駄にコストがかかるので一度生成したインスタンスをマップに保存して,あれば再利用するように変更します.

object Container {
        ・
        ・
    private val instanceStore = HashMap<KClass<*>, Any>()
        ・
        ・
}

Containerのgetは以下のように変更されます.

public fun <T : Any> get(clazz: KClass<T>): T {
    val constructor = map[clazz]?.primaryConstructor ?: throw Exception("${clazz.simpleName} is not found")
    val params = constructor.parameters
         .map {
            val kClass = it.type.classifier as KClass<*>
            map[kClass] ?: throw Exception("$kClass is not found in this Container")
        }
        .map { get(it) }
        .toTypedArray()
    return instanceStore.getOrPut(clazz, { constructor.call(*params) as T }) as T

最後の部分でMapのgetOrPutで既に作成されていればそれを,なければcontructorを呼んでインスタンスを生成し登録してそれを返す,という実装にしました. mainを実行すると

call driver's constructor
execute usecase
execute gateway
execute driver
execute gateway
execute driver

となりdriverのコンストラクタが一回しか実行されていないことがわかります.

まとめ

今回はDIコンテナのかなり基本的な機能を実現しました.他にもアノテーションがついてる時に自動的に登録したり,AOPを実現したりとあった方がいい機能はたくさんあります.以下のサイトがそのあたりとても参考になるので是非読んで頂きたいです.

https://nowokay.hatenablog.com/entry/20160406/1459918560

今回僕が作成したサンプルは以下においています.

https://github.com/Yoshiaki-Harada/di-container

KtorでCRUDアプリを作成する ~STEP5 : エラーハンドリング

この記事はKtorでCRUDアプリを作成することを目標としています.前回のSTEP4

oboe-note.hatenablog.com

ではKodeinと使って,Clean Architectureに従った設計に修正をしました.

今回の目標

前回まで作成したCRUDアプリはエラーハンドリングについてあまり考えておらずハンドラー部分がデータの存在を確認し404 not foundを返すぐらいのことしか行っていませんでした.今回はKtorの機能を使って,もっとうまくエラーハンドリングを行おうと思います!

エラーハンドリングのイメージ

前回

冒頭でもお話しましたが前回はハンドラーがデータの存在を判定し404 not foundを返すという実装になっていました.

f:id:harada-777:20200505183333p:plain:h100

実際のコードはこんな感じでした.

put("students/{id}") {
    val id = call.parameters["id"]!!.toInt()
    val name = call.request.queryParameters["name"]!!
    val students = usecase.getStudents()

    // ここで判定
    if (students.indexOfFirst { it.id == id } < 0) {
        call.respond(HttpStatusCode.NotFound, JsonResponse("id $id is not found"))
        return@put
    }
    usecase.updateStudent(Student(id, name))
    call.respond(JsonResponse("id $id is updated"))
}

ここで色々やるとハンドラーが複雑になってしまったり,同じように404を返す部分全てでこういうことをやらなくてはなりません..

今回の実装

そこで今回はそれぞれのクラス(今回はRepository)が例外をスローし,Ktorの機能で例外に応じたレスポンスを返すという実装にしようと思います.

f:id:harada-777:20200505183328p:plain:h200

こうすることで以下のメリットが生まれます + ハンドラーがシンプルになる + より内側の層(例えばUsecaseなどのロジックのある層)が,nullなどのデータの存在の判定をしなくて済む 2つ目について軽く説明します.例えばRepositoryに対してidを指定してデータを取得したあとにUsecseなどでロジックを適用するときを考えてみます.Repositoryの戻り値が以下のようだったとします.

    fun findById(id: Int): Student?

この場合Usecaseでrepositoryを使用する場合こんな感じ,nullを気にして?を使ったコードになります.

 repository.findById(id)?.let { it ->
        //何らかの処理..
    }

Kotlinは比較的シンプルにnullを扱えますがあまりこういうコードが複雑化する原因になるのでやりたくはありません.Repository自身がデータがないときに例外をスローする実装にするとrepositoryの戻り値としてnullが返らなくなるのでRepositoryを使う側がnullを気にせず済みます!

実装

実際にコードを書いていきます.今回修正がないコードも一応再掲しておきます.

再掲(build.gradle, Dao, Entity, Port, Injector)

// build.gradle
buildscript {
    repositories {
        jcenter()
    }
    
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

apply plugin: 'kotlin'
apply plugin: 'application'
apply plugin: 'kotlin-kapt'

group 'com.example'
version '0.0.1'
mainClassName = "io.ktor.server.netty.EngineMain"

sourceSets {
    main.kotlin.srcDirs = main.java.srcDirs = ['src']
    test.kotlin.srcDirs = test.java.srcDirs = ['test']
    main.resources.srcDirs = ['resources']
    test.resources.srcDirs = ['testresources']
}

repositories {
    mavenLocal()
    jcenter()
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
    implementation "io.ktor:ktor-server-netty:$ktor_version"
    implementation "ch.qos.logback:logback-classic:$logback_version"
    implementation "io.ktor:ktor-gson:$ktor_version"
    implementation 'org.kodein.di:kodein-di-generic-jvm:6.5.0'
    compile 'io.requery:requery:1.6.1'
    compile 'io.requery:requery-kotlin:1.6.1'
    kapt 'io.requery:requery-processor:1.6.1'
    compile group: 'mysql', name: 'mysql-connector-java', version: '8.0.19'
    testImplementation "io.ktor:ktor-server-tests:$ktor_version"
}

Kodeinを使った依存性注入の定義です.詳しくはこちらをご覧ください.

// Injector.kt
import com.mysql.cj.jdbc.MysqlDataSource
import io.requery.Persistable
import io.requery.sql.KotlinConfiguration
import io.requery.sql.KotlinEntityDataStore
import org.kodein.di.Kodein
import org.kodein.di.generic.bind
import org.kodein.di.generic.instance
import org.kodein.di.generic.singleton

object Injector {
    val usecaseModule = Kodein.Module("usecase") {
        bind<StudentUsecase>() with singleton { StudentUsecaseImpl(instance()) }
    }

    val portModule = Kodein.Module("port") {
        bind<StudentPort>() with singleton {StudentRepository(instance(), instance())}
    }

    val daoModule = Kodein.Module("dao") {
        bind<StudentDao>() with singleton { StudentDao() }
    }
    // ExposedのDatabase接続定義
    val dataSourceModule = Kodein.Module("dataSoure") {
        bind<KotlinEntityDataStore<Persistable>>() with singleton {
            val dataSource = MysqlDataSource().apply {
                serverName = "127.0.0.1"
                port = 3306
                user = "root"
                password = "mysql"
                databaseName = "school"
            }
            KotlinEntityDataStore<Persistable>(KotlinConfiguration(dataSource = dataSource, model = Models.DEFAULT))
        }
    }

    public val kodein = Kodein {
        importAll(usecaseModule, portModule, daoModule, dataSourceModule)
    }
}

requeryを使ったエンティティの定義です.詳しくはこちらをご覧ください.

// StudentEntity.kt
import io.requery.Entity
import io.requery.Key
import io.requery.Persistable
import io.requery.Table

@Entity
@Table(name = "students")
interface StudentEntity : Persistable {
    @get:Key
    var id: Int
    var name: String
}

requeryを使ったDAOです.詳しくはこちらをご覧ください.前回にはなかったですがIdを指定してStudentを得るfindByIdを追加しています.

// StudentDao.kt
import io.requery.kotlin.eq

class StudentDao {

    fun findAll(dataStore: Store): List<Student> =
        dataStore.select(Student::class).get().toList()

    fun findById(id: Int, dataStore: Store): Student? {
        val cond = Student::id eq id
        return dataStore.select(Student::class).where(cond).get().firstOrNull()
    }

    fun create(student: Student, dataStore: Store) {
        dataStore.insert(student)
    }

    fun update(student: Student, dataStore: Store) {
        dataStore.update(student)
    }

    fun delete(id: Int, dataStore: Store): Int {
        val cond = Student::id eq id
        return dataStore.delete(Student::class).where(cond).get().value()
    }
}

findByIdは指定したIdのStudentがいなかったらnullを返します.

Usecaseが要求するInterfaceであるPort.

// StudentPort.kt
interface StudentPort {
    fun findAll(): List<Student>
    fun findById(id: Int): Student
    fun create(student: Student)
    fun update(student: Student)
    fun delete(id: Int)
}

例外をスローするRepository

今回の目的を達成するためのRepositoryに修正を加えます.主に以下の2つの例外を投げるようにしました. + 指定したIdの生徒が存在しないとき -> NotFoundStudentException + 既に存在するIdの生徒をもう一度作ろうとしたとき -> PersistenceStudentException

NotFoundStudentExceptionについて説明します.以下のようなクラスを作成しました.

class NotFoundStudentException(id: Int) : Throwable("student id: $id is not found")

そして指定したidのStudentがいない場合以下のようにNotFoundStudentExceptionをスローします.dao.findByIdStudent?を返します.

override fun findById(id: Int): Student = dao.findById(id, dataStore) ?: throw NotFoundStudentException(id)

次にupdate部分においても指定したidのStudentが存在しないときNotFoundStudentExceptionをスローするようにします.

override fun update(student: Student) {
     this.findById(student.id)
     dataStore.withTransaction {
         dao.update(student, this)
     }
}

requeryはupdateは指定したidのデータがないとき何もしないだけなのでこちらで一度findByIdを呼ぶことでデータの存在を確認することにしました.

次にdeleteでもNotFoundStudentExceptionをスローさせます.

override fun delete(id: Int) {
    val count = dataStore.withTransaction {
        dao.delete(id, this)
     }
        if (count == 0) throw NotFoundStudentException(id)
    }
}

requeryは削除したカウントを返すのでcountが0のときそのデータは存在しなかったということなので上記のように実装しました.

次にPersistenceStudentExceptionについて説明します.次のようなクラスを用意します.

class PersistenceStudentException(id: Int) : Throwable("student id: $id is already persist")

requeryは既に存在するPRIMARY KEY (今回はid)のデータをもう一度作ろうとするとPersistenceExceptionをスローします.なのでrunCatchingでその例外をResult型でラップしてPersistenceExceptionのときはPersistenceStudentExceptionをスローしなおす実装を行います.

override fun create(student: Student) {
        kotlin.runCatching {
            dataStore.withTransaction {
                dao.create(student, this)
            }
        }.onFailure {
            when (it) {
                // requeryのcreateは既にPRIMARY KEYが存在するものを作成するとPersistenceExceptionを投げる
                is PersistenceException -> throw PersistenceStudentException(student.id)
                else -> {
                    throw it
                }
            }
        }
    }

全体は以下のようになります.

// StudentRepository.kt
typealias Store = EntityStore<Persistable, Any>

class NotFoundStudentException(id: Int) : Throwable("student id: $id is not found")
class PersistenceStudentException(id: Int) : Throwable("student id: $id is already persist")

class StudentRepository(private val dataStore: KotlinEntityDataStore<Persistable>, private val dao: StudentDao) :
    StudentPort {
    override fun findAll(): List<Student> = dao.findAll(this.dataStore)

    override fun findById(id: Int): Student = dao.findById(id, dataStore) ?: throw NotFoundStudentException(id)

    override fun create(student: Student) {
        kotlin.runCatching {
            dataStore.withTransaction {
                dao.create(student, this)
            }
        }.onFailure {
            when (it) {
                // requeryのcreateは既にPRIMARY KEYが存在するものを作成するとPersistenceExceptionを投げる
                is PersistenceException -> throw PersistenceStudentException(student.id)
                else -> {
                    throw it
                }
            }
        }
    }

    override fun update(student: Student) {
        this.findById(student.id)
        dataStore.withTransaction {
            dao.update(student, this)
        }
    }

    override fun delete(id: Int) {
        val count = dataStore.withTransaction {
            dao.delete(id, this)
        }
        if (count == 0) throw NotFoundStudentException(id)
    }
}

Repositoryを呼ぶUsecase

// StudentUsecase.kt
interface StudentUsecase {
    fun getStudents(): List<Student>
    fun getStudentById(id: Int): Student
    fun createStudent(student: Student)
    fun updateStudent(student: Student)
    fun deleteStudent(id: Int)
}

今回は特にロジックがないのでただPort (Repository)を呼ぶだけとなっていますが実際にロジックを書く際にデータの有無を気にせず書くことができます.

// StudentUsecaseImpl.kt
class StudentUsecaseImpl(val port: StudentPort) : StudentUsecase {
    override fun getStudents(): List<Student> = port.findAll()
    override fun getStudentById(id: Int): Student = port.findById(id)
    override fun createStudent(student: Student) = port.create(student)
    override fun updateStudent(student: Student) = port.update(student)
    override fun deleteStudent(id: Int) = port.delete(id)
}

Handler (Kodeinによる例外とResponseのマッピング)

Kodeinは様々なフィーチャを追加することができます.今回使うのはStatusPages フィーチャです.以下のように使用することができます.

install(StatusPages) {
    //例外とレスポンスのマッピング        
}

Status Pagesはexception<補足したい例外>{返したいレスポンス} の形で定義をしていきます.上から優先的に例外のマッチングが行われます.NotFoundStudentExceptionが投げられると404 not foundが返るように定義すると以下のようになります.

    install(StatusPages) {
        exception<NotFoundStudentException> { cause ->
            val errorMessage: String = cause.message ?: "Unknown error"
            call.respond(HttpStatusCode.NotFound, JsonErrorResponse(errorMessage))
        }     
    }
data class JsonErrorResponse(val error: String)

このマッピングは複数登録することができ上から順にマッチングしていきます.PersistenceStudentExceptionの定義とその他の例外にも対応させます.

install(StatusPages) {
    exception<NotFoundStudentException> { cause ->
        val errorMessage: String = cause.message ?: "Unknown error"
        call.respond(HttpStatusCode.NotFound, JsonErrorResponse(errorMessage))
        }
    exception<PersistenceStudentException> { cause ->
        val errorMessage: String = cause.message ?: "Unknown error"
        call.respond(HttpStatusCode.Conflict, JsonErrorResponse(errorMessage))
        }
    exception<Throwable> { cause ->
        val errorMessage: String = cause.message ?: "Unknown error"
        call.respond(HttpStatusCode.InternalServerError, JsonErrorResponse(errorMessage))
        }
    }

次にハンドラーでパスパラメータやクエリパラメータのバリデーションも追加します.その例外を表すクラスが以下のものです.

class ValidationError(override val message: String) : Throwable(message)

まずidに関するバリデーションを行います.KtorのParamatersを受け取ってInt型のidを返す関数を作成します.この関数はidがnullのときとパースできない場合に例外を投げます.

private fun getId(parameters: Parameters): Int = runCatching {
    // toInt()ができないときとidがnullのときに例外がスローされる
    parameters["id"]?.toInt() ?: throw ValidationError("id is't must be null")
}.getOrElse {
    throw ValidationError(it.message ?: "Unkown error")
}

idが欲しい部分でこれを呼ぶとidを取得できます.

delete("students/{id}") {
    val id = getId(call.parameters)
    usecase.deleteStudent(id)
    call.respond(JsonResponse("id $id is deleted"))
}

Putはidをパスパラメータ,nameをqueryパラメータで受け取ってStudentを更新する仕様なのでgetIdを使うとともに名前がnullのとき例外をスローします.

put("students/{id}") {
    val id = getId(call.parameters)
    val name = call.request.queryParameters["name"]?:throw ValidationError("name is't must be null")
    usecase.updateStudent(Student(id, name))
    call.respond(JsonResponse("id $id is updated"))
}

StatusPagesにValidationErrorを追加します.

exception<ValidationError> { cause ->
    val errorMessage: String = cause.message ?: "Unknown error"
}

ハンドラー全体のコードは以下のようになります.

// Application.kt
import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.ContentNegotiation
import io.ktor.features.StatusPages
import io.ktor.gson.gson
import io.ktor.http.HttpStatusCode
import io.ktor.http.Parameters
import io.ktor.request.receive
import io.ktor.response.respond
import io.ktor.routing.*
import org.kodein.di.generic.instance

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

data class JsonResponse(val message: String)
data class JsonErrorResponse(val error: String)

class ValidationError(override val message: String) : Throwable(message)

@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.module(testing: Boolean = false) {
    val usecase by Injector.kodein.instance<StudentUsecase>()
    install(ContentNegotiation) {
        gson {
            setPrettyPrinting()
        }
    }
    install(StatusPages) {
        exception<NotFoundStudentException> { cause ->
            val errorMessage: String = cause.message ?: "Unknown error"
            call.respond(HttpStatusCode.NotFound, JsonErrorResponse(errorMessage))
        }
        exception<PersistenceStudentException> { cause ->
            val errorMessage: String = cause.message ?: "Unknown error"
            call.respond(HttpStatusCode.Conflict, JsonErrorResponse(errorMessage))
        }
        exception<ValidationError> { cause ->
            val errorMessage: String = cause.message
            call.respond(HttpStatusCode.BadRequest, JsonErrorResponse(errorMessage))
        }
        exception<Throwable> { cause ->
            val errorMessage: String = cause.message ?: "Unknown error"
            call.respond(HttpStatusCode.InternalServerError, JsonErrorResponse(errorMessage))
        }
    }
    routing {
        get("/students") {
            call.respond(usecase.getStudents())
        }

        get("/students/{id}") {
            val id = getId(call.parameters)
            call.respond(usecase.getStudentById(id))
        }

        post("/students") {
            val inputJson = call.receive<Student>()
            usecase.createStudent(inputJson)
            call.respond(JsonResponse("id ${inputJson.id} is created"))
        }

        put("students/{id}") {
            val id = getId(call.parameters)
            val name = call.request.queryParameters["name"]?:throw ValidationError("name is't must be null")
            usecase.updateStudent(Student(id, name))
            call.respond(JsonResponse("id $id is updated"))
        }

        delete("students/{id}") {
            val id = getId(call.parameters)
            usecase.deleteStudent(id)
            call.respond(JsonResponse("id $id is deleted"))
        }
    }
}

private fun getId(parameters: Parameters): Int = runCatching {
    parameters["id"]?.toInt() ?: throw ValidationError("id is't must be null")
}.getOrElse {
    throw ValidationError(it.message ?: "Unkown error")
}

実際にrequestを投げて見ましょう. まずはデータを作成します.

f:id:harada-777:20200505183323p:plain:h400

存在しないid=2でリクエストしてみます.

f:id:harada-777:20200505183313p:plain:h200

もう一度id=1でリクエストしてみます.

f:id:harada-777:20200505183317p:plain:h400

int型ではないidでリクエストしてみます.

f:id:harada-777:20200505183306p:plain:h200

想定通りのレスポンスが返ってきました!

まとめ

今回はKtorを使ったアプリケーションでどうやってエラーハンドリングを行うかについて解説しました.とりあえずこのCRUDアプリ作成シリーズはこの辺で終了かなと思っています.

KtorでCRUDアプリを作成する ~STEP4 : Clean Architecture (Kodeinを使用)

この記事はKtorでCRUDアプリを作成することを目標としています.前回のSTEP3

oboe-note.hatenablog.com

では主にRequeryを使ってDBを操作する方法について紹介しました.

今回の目標

今回はClean Achitectureの考え方に従って依存性の方向をより下位の概念(DBなどの技術的詳細)が上位の概念(解決すべき対象に関する知識やロジック)に依存するように依存関係を制御しようと思います.(依存とは? 例.あるAがBを知っている = AはBに依存する といいます) これを実現するために今回はKotlin性のDIツールであるKodeinを使用します. まずClean ArchitectureやDIPについて説明します.そして実際にKodeinを使って依存性を注入していきます.なのでKodeinの使い方を早く知りたい!って方はいきなりKodeinを使ったDI (Dependency Injection)へ飛んで頂きたいです.

Clean Architecture

まずはClean Architectureについて軽くですが説明をさせて頂きます.詳しい説明はクリーンアーキテクチャ(The Clean Architecture翻訳)Clean Architectureなどを見て頂くのがいいと思います!. クリーンアーキテクチャはRobert C. Martin(Uncle Bob)が提唱したDBやフレークワークからの独立性を確保するためのアーキテクチャです。クリーンアーキテクチャの概念図としてよく登場するのが以下の図です. f:id:harada-777:20200504103650p:plain:h300

  • Enterprise Business Rules
    • このアプリケーションで解決したい対象におけるビジネスルールが集まる層です.これが上位でありこのアプリケーションにとって最も重要な概念です.
  • Application Business Rules
    • このアプリケーションの使い方に関するルールが集まる層です.
  • Interface Adapters
    • より上位の層と下位の層を繋ぐ層です.ビジネスルールにとって使いやすいデータ構造とDatabaseが使いやすいデータ構造の変換などを担います.
  • Frameworks & Drivers
    • ウェブフレームワークやORマッパーなどDBの接続を担うツールが集まる層である.これはこのアプリケーションにとって一番下位な概念であり技術的な詳細です.

この図はClean Architectureが必ずこの4層でないといけないということを表してはいません.重要なの赤で囲った部分です.このように依存の向きがより上位概念に向かっているということが重要なのです!(僕はそう理解しています..).ビジネスルールの層の実装が接続するDatabaseの仕様に合わせた実装をしてはいけないという指針です. こうすることで技術的な部分を隠蔽し,いつでも実装を差し替えることが拡張性の高い設計となります. このように依存の向きをコントロールするのに必要な方法が依存性逆転の原則(Dependency Inversion Principle; DIP)です.次の節で簡単にですが紹介します.

DIPの為の準備 (Interfaceを作成)

この節ではまずDIPについて説明し,それを実現する為にInterfaceを以前までに作成したアプリケーションに対して定義します.

DIP

DIPとは素直にプログラムを実装してしまうと,DBやライブラリに合わせて自分のプログラムを設計してしまうところを,何とかして逆にDBやライブラリの方の実装を自分のプログラムに合わせるように実装できるようにしようという方針です.

f:id:harada-777:20200504103659p:plain:h200

このときに使う手段としてInterfaceが上がられます.自分達が要求するInterfaceを用意してそれに合わせて,DBやライブラリに関する実装を記述するようにします.そうすることで矢印の向きを逆転させることができます!.こうしてより下位の概念がより上位の概念に依存するというClean Architectureの原則を守ことができます.

Interfaceを定義する

ここからは実際にプログラムを修正していきます.以前はHandlerが直接Daoを呼んでいましたが今回はUsecaseを通してデータを取得することにします.Usecaseが求めるInterfaceであるPortをDaoに依存するRepositoryが実装することで依存の向きを逆転させます.

f:id:harada-777:20200504103645p:plain:h250

以下が以前に作成したDaoです.ORマッパーにRequeryを使用しています.

//StudentDao.kt
import io.requery.Persistable
import io.requery.kotlin.EntityStore
import io.requery.kotlin.eq

class StudentDao {

    fun findAll(dataStore: EntityStore<Persistable, Any>): List<Student> =
        dataStore.select(Student::class).get().toList()

    fun create(student: Student, dataStore: Store) {
        dataStore.insert(student)
    }

    fun update(student: Student, dataStore: Store) {
        dataStore.update(student)
    }

    fun delete(id: Int, dataStore: Store) {
        dataStore.delete(Student::class).where(Student::id eq id).get().value()
    }
}

まずPortを定義します.

//StudentPort.kt
interface StudentPort {
    fun findAll(): List<Student>
    fun create(student: Student)
    fun update(student: Student)
    fun delete(id: Int)
}

それを実装するRepositoryを作成します. ここでトランザクション境界を貼っています.

// StudentRepositoy.kt
import io.requery.Persistable
import io.requery.kotlin.EntityStore
import io.requery.sql.KotlinEntityDataStore
typealias Store = EntityStore<Persistable, Any>

class StudentRepository(private val dataStore: KotlinEntityDataStore<Persistable>, private val dao: StudentDao) :
    StudentPort {
    override fun findAll(): List<Student> = dao.findAll(this.dataStore)

    override fun create(student: Student) {
        dataStore.withTransaction {
            dao.create(student, this)
        }
    }

    override fun update(student: Student) {
        dataStore.withTransaction {
            dao.update(student, this)
        }
    }

    override fun delete(id: Int) {
        dataStore.withTransaction {
             dao.delete(id, this)
        }
    }
}

UsecaseのInterfaceと実装を記述します.

//StudentUsecase.kt
interface StudentUsecase {
    fun getStudents(): List<Student>
    fun createStudent(student: Student)
    fun updateStudent(student: Student)
    fun deleteStudent(id: Int)
}
//StudentUsecaseImpl.kt
class StudentUsecaseImpl(val port: StudentPort) :StudentUsecase{
    override fun getStudents(): List<Student> = port.findAll()

    override fun createStudent(student: Student) = port.create(student)

    override fun updateStudent(student: Student) = port.update(student)

    override fun deleteStudent(id: Int) = port.delete(id)
}

StudentUsecaseImplを見て頂くとDaoの情報の情報が全く含まれていないことがわかります.このようにしInterfaceをうまく使うことで技術的な詳細を切り離すことができます.ここまでくるとHandlerはこのUsecaseを呼べばいいことになります.しかしStudentUsecaseImplはStudentPortの実装 (StudentRepository)をコンストラクタで渡すなど依存性を注入(DI)しなくてはなりません.これをサポートしてくれるのがKodeinです!

Kodeinを使ったDI (Dependency Injection)

KodeinとはKotlin製のDI Frameworkです.SpringのAnnotationを使ったInjectionではなくKotoinコードで依存関係を表します.(僕はこっちの方が好みです..) この節ではKodeinの使い方を主に説明しようと思います.

gradleにdependencyを追加

build.gradledependencyを追加します.

//build.gradle
dependencies {
        implementation 'org.kodein.di:kodein-di-generic-jvm:6.5.0'
}

さらに僕の場合は以下の記述をbuild.gradleに追加しないと動きませんでした..

sourceCompatibility = 1.8
compileKotlin {
    kotlinOptions {
        freeCompilerArgs = ["-Xjsr305=strict"]
        jvmTarget = "1.8"
    }
}
compileTestKotlin {
    kotlinOptions {
        freeCompilerArgs = ["-Xjsr305=strict"]
        jvmTarget = "1.8"
    }
}

Kodeinは古いJavaだと動かない可能性があるので注意してください.Java13.0は動きます.

InjectionとRetrive

Kodeinでは2種類の依存性注入方法をサポートしています.まずはInjectionについて説明します. KodeinのInjectionと呼ばれる方法はコンストラクタインジェクションのことを指します.例を用いて説明します. 計算用のCalc Interfaceを定義します

interface Calc {
    // d1とd2を使って計算
    fun calc(d1: Double, d2: Double): Double
    // default値とdを使って計算
    fun calcDefault(d: Double): Double
}

足し算をするクラスを作成しClacを実装します.

class AddInjection(private val default: Double) : Calc {
    override fun calc(d1: Double, d2: Double): Double {
        return d1 + d2
    }

    override fun calcDefault(d: Double): Double {
        return default + d
    }
}

今回はAddInjectionにdefaultを注入します.これをKodeinを使って定義すると以下のようになります.

val kodeinInjection = Kodein {
    bind<Calc>() with provider { AddInjection(instance()) }
    bind<Double>() with provider { 5.0 }
}

Kodeinにブロックで定義を渡します.こうすることで様々なインスタンスがKodeinの管理下になります. bind<依存注入対象の型> with provider { 依存を注入対象のコンストラクタ(値) } とすることで依存を注入することができます.さらにコンストラクタ引数に渡しているinstanse()はKodeinの管理下にあるインスタンスからコンストラクタ引数の型にあるインスタンスを自動で渡してくれます! 今回2行目のでDouble型に5.0をbindすると定義したのでdefaultには5.0が渡ります. 依存性が注入された実態を取り出したいときはKotlinの委譲を使います.kodein.instanse<型>()で指定した方の実体を取り出すことができます.

fun main() {
    val add by kodeinInjection.instance<Calc>()
    println("result ${add.calc(1.0, 2.0)}")
    println("result ${add.calcDefault(1.0)}")
}

kodein.instanse<型>()で指定した方の実体を取り出すことができます.実行すると以下の結果がとなり5.0が注入されていることがわかります.

result 3.0
result 6.0

次にRetriveと呼ばれる方法について説明します.こちらはKotlinに委譲プロパティを用いる方法です.この方法はクラスにはkodeinインスタンスを渡し,プロパティをKodeinを使って初期化します.

class AddRetrieve(private val kodein: Kodein) : Calc {
    private val default by kodein.instance<Double>() //ここが違う!
    override fun calc(d1: Double, d2: Double): Double {
        return d1 + d2
    }

    override fun calcDefault(d: Double): Double {
        return default + d
    }
}

Kodeinの依存の定義の仕方は変わりません.

val kodeinRetrieve = Kodein {
    bind<Calc>() with provider { AddRetrieve(kodein) }
    bind<Double>() with provider { 10.0 }
}
fun main() {
    val add: Calc by kodeinRetrieve.instance<Calc>()
    print("result ${add.calc(1.0, 2.0)}")
    print("result ${add.calcDefault(1.0)}")
}

実行すると2つ目の出力が11.0となり10.0でdefaultが初期化されていることがわかります.

result 3.0
result 11.0
比較

2つの方法を比較するとRetriveは依存を注入したい対象がKodeinに依存してしまう欠点があります..公式サイトによるとそのかわりに便利な機能が提供されるそうですが,僕は対象がKodeinに依存するのが嫌なので今回はInjectionを使って説明をしていきます.

Tag

Kodeinが型で自動的にコンストラクタに値を渡してくれることをお話しました.では型は同じであるが別々の値を渡したいときはどうすればいいでしょうか? Kodeinはそれを可能とするTagという機能を用意しています. 先ほどのClac Interfaceを用いて説明します.片方の実体にはdefault=5.0を注入し,もう片方の実体にはdefault=1.0を注入します.

val kodeinInjection = Kodein {
    bind<Calc>(tag="add1") with provider { AddInjection(instance(tag="add1")) }
    bind<Double>(tag="add1") with provider { 5.0 }
    bind<Calc>() with provider { AddInjection(instance(tag="add2")) }
    bind<Calc>(tag="add2") with provider { AddInjection(instance(tag="add2")) }
    bind<Double>(tag="add2") with provider { 1.0 }
}

どれを注入するかをtagを使って定義しています.注入すべき依存性(Double型)はbindの引数でtagを指定し,注入の対象(AddInjection)にはinstanseの引数でtagを指定する.そして注入された実体にもtagを指定して,使用したい箇所で実態をtagをつけて取り出すこともできます.

fun main() {
    val add1 by kodeinInjection.instance<Calc>(tag = "add1")
    val add2 by kodeinInjection.instance<Calc>(tag = "add2")
    println("result ${add1.calcDefault(1.0)}")
    println("result ${add2.calcDefault(1.0)}")
}

以上が基本的なKodeinの説明です.

KodeinのCRUDアプリへの適用

ここから実際にCRUDアプリに対してKodeinを使って依存性を注入していきます.

Module

ここで今回依存関係を定義するときにKodeinのModule機能を使って責務ごとにKodeinModuleを作成し,それらを全てimportしたkodeinを作成する形をとります.今回のアプリケーションの大きさではここまでする必要はないですが,大きなアプリケーションを作成する際に依存関係をわかりやすく記述することができます.

import com.mysql.cj.jdbc.MysqlDataSource
import io.requery.Persistable
import io.requery.sql.KotlinConfiguration
import io.requery.sql.KotlinEntityDataStore
import org.kodein.di.Kodein
import org.kodein.di.generic.bind
import org.kodein.di.generic.instance
import org.kodein.di.generic.singleton

object Injector {
    val usecaseModule = Kodein.Module("usecase") {
        bind<StudentUsecase>() with singleton { StudentUsecaseImpl(instance()) }
    }

    val portModule = Kodein.Module("port") {
        bind<StudentPort>() with singleton {StudentRepository(instance(), instance())}
    }

    val daoModule = Kodein.Module("dao") {
        bind<StudentDao>() with singleton { StudentDao() }
    }
    // RequeryのDatabase接続定義 (以前はAppkication.ktに記述)
    val dataSourceModule = Kodein.Module("dataSoure") {
        bind<KotlinEntityDataStore<Persistable>>() with singleton {
            val dataSource = MysqlDataSource().apply {
                serverName = "127.0.0.1"
                port = 3306
                user = "root"
                password = "mysql"
                databaseName = "school"
            }
            KotlinEntityDataStore<Persistable>(KotlinConfiguration(dataSource = dataSource, model = Models.DEFAULT))
        }
    }
    // 全moduleをimportしたkodein
    public val kodein = Kodein {
        importAll(usecaseModule, portModule, daoModule, dataSourceModule)
    }
}

最後の方のkodeinには全てのmoduleのインスタンスが管理下にあるので,ここを通して実態を取り出すことで,依存性が全て注入された実態を取り出すことができます.

Singleton

また今回bindする際に providerではなくsingletonを採用しました.こうすることでsingletonオブジェクトとして実態を生成しプログラム全体でこのインスタンスを共有することができます.

Handlerの修正

これを使ってHandler部分を書き直したのが以下です.

import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.ContentNegotiation
import io.ktor.gson.gson
import io.ktor.http.HttpStatusCode
import io.ktor.request.receive
import io.ktor.response.respond
import io.ktor.routing.*
import org.kodein.di.generic.instance

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

data class JsonResponse(val message: String)

@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.module(testing: Boolean = false) {
    // kodeinを通してusecaseを取り出す.
    val usecase by Injector.kodein.instance<StudentUsecase>()
    install(ContentNegotiation) {
        gson {
            setPrettyPrinting()
        }
    }
    routing {
        get("/students") {
            call.respond(usecase.getStudents())
        }

        post("/students") {
            val inputJson = call.receive<Student>()
            usecase.createStudent(inputJson)
            call.respond(JsonResponse("id ${inputJson.id} is created"))
        }

        put("students/{id}") {
            val id = call.parameters["id"]!!.toInt()
            val name = call.request.queryParameters["name"]!!
            val students = usecase.getStudents()

            if (students.indexOfFirst { it.id == id } < 0) {
                call.respond(HttpStatusCode.NotFound, JsonResponse("id $id is not found"))
                return@put
            }
            usecase.updateStudent(Student(id, name))
            call.respond(JsonResponse("id $id is updated"))
        }

        delete("students/{id}") {
            val id = call.parameters["id"]!!.toInt()
            val students = usecase.getStudents()

            if (students.indexOfFirst { it.id == id } < 0) {
                call.respond(HttpStatusCode.NotFound, JsonResponse("id $id is not found"))
                return@delete
            }

            usecase.deleteStudent(id)
            call.respond(JsonResponse("id $id is deleted"))
        }
    }
}

val usecase by Injector.kodein.instance<StudentUsecase>()ここが実際にusecaseを取り出している部分です.

まとめ

今回はClean Architectureを実現する為にKodeinを使用した実装を解説しました.次回はKodeinでのエラーハンドリングについて紹介しようと思います.