開発体験(DX)改善について知るために2021年1Qに読んだリソース

ここで開発体験(DX: developer experience)はおおむね次の記事で説明されている概念とする。digital transformationではない。

DX: Developer Experience (開発体験)は重要だ - Islands in the byte stream

2021年になり、担当するWebサービスのDXの改善を任務とするチームに所属しはじめた。こういうチームができるぐらいなので、開発体験を悪化させるという感覚を開発者に与える課題はいくつかわかっている。それらを一つ一つ解決していけば、改善業務をある程度は進められそうだと考えてはいた。一方で、あくまで各開発者の感覚に依存して課題を把握していることが多いので*1どれぐらいよく/悪くなっているかはわかりにくく、改善がどの程度Webサービスのビジネスそのものに影響するのかというのもぼんやりとしていた。

こういう不安感はDXの改善についての体系的な知識に疎かったのにも原因があるだろうと思い、そのようなテーマに関連しそうな書籍や記事を読んでみた。

わかったこと

ソフトウェア開発に科学を持ち込むという考えが大事なことがわかった。といっても難しい話ではなく、バリューストリームのうち、開発者が大きく関わる部分(つまりDXが効果を発揮する部分)に測定可能なメトリクス

  • リードタイム
  • デプロイ頻度
  • MTTR (Mean Time To Repair)
  • 変更失敗率

があり、上記のメトリクスを参考にしてDXの改善をめざすことで、結果的にバリューストリームの先にいる顧客がサービスで価値を得られるまでの時間を短くできるということだった。これは市場におけるサービスの競争力が上がることにつながる。さらに、開発者が効率的に開発できる方法も突き詰めていくことになるので、結果として障害発生率の低下や燃え尽き症候群の防止にも効果がある。

読んだリソース

Maximizing Developer Effectiveness

2021年に入ってDXの改善を担当するチームに入ってからTwitterのタイムラインを眺めていたときに、タイミングよくMartin Flower氏のツイートで知った記事。Fowler氏のWebサイトに載っているThoughtWorksのメンバーTim Cochran氏による記事。

Maximizing Developer Effectiveness

開発効率の最大化におけるマイクロフィードバックループの重要性について説いている。例えば、マイクロフィードバックループの一例として、実装中のコードのテストを手元で実行して結果を見たいとする。このテストの実行速度が数十秒遅いだけでも、手元でのテストは1日に何度も実行することがあるので、塵も積もれば山となり開発者の効率は長期的にかなり下がってしまう。しかも、そのようなマイクロフィードバックループの劣化は気づきにくいので、そのような問題を解決していくことが、より上位のメトリクスの改善のために重要だという。

他にも、Spotifyが技術情報ポータルのシステムを自作し、そこにサービスカタログなどを置くことで、情報を探す時間を削減している事例が紹介されている*2

この記事で次に紹介する"Accelerate"について言及されていたので、次はそれを読むことにした。

Accelerate

書籍。邦題は『LeanとDevOpsの科学』

この本はDevOps界隈では有名な本のよう。その目的として、統計的に有意な方法でパフォーマンスの改善を促すケイパビリティ*3を特定・理解する方法を確立すると述べられている。ソフトウェアのデリバリー速度を改善するには何が必要かを先に述べてから、その科学的な検証プロセスを具体的に説明した本。なので、後半は統計学や計量心理学の話がかなり出てくる。

要点は、上述したケイパビリティを表すメトリクスとしてリードタイムデプロイ頻度MTTR変更失敗率を改善すれば高速なデリバリーが可能であることを特定したので、これを強化していってください、という内容。それぞれのメトリクスの詳細は次のとおり:

  • リードタイム
    • コードのコミットから本番稼働までの所要時間
  • デプロイ頻度
    • バッチサイズ(一度にやる作業の量)を減らすとサイクルタイム短縮/変動の低減、高速なフィードバックの取得などメリットがあるが、バッチサイズの測定が難しいのでデプロイ頻度で考える
  • MTTR
    • パフォーマンスの高い組織が安定性を犠牲にしているかどうか調べたい
    • サービスをやっていたら絶対に失敗するので、サービス停止や機能障害の復旧速度をモニタリングする
  • 変更失敗率
    • デプロイ後になんらかの修正が必要だった場合の比率

また、「デリバリ速度とシステム安定性/品質はトレードオフである」という言説はハイパフォーマーには当てはまらないことがわかった、ということも述べられており、重要な点だと思う。他には、ソフトウェアがビジネスそのものになりつつあるなか、ソフトウェアデリバリーの速度を改善すると組織の業績も向上するという結果が出ており、ソフトウェア開発能力を会社の中核として据えるべき根拠と言えるという。

著者の一人Forsgren博士は現在GitHubのVP, Research & Strategyに就いている。この本で紹介されている上述のメトリクスを測定する機能がGitHubのロードマップにも入っている。実装されるのが楽しみ。

Insights: Engineering Leader and Organizational DORA Reports · Issue #127 · github/roadmap

Forsgren博士は最近だとコロナ禍でのリモートワーク環境における開発者の生産性の分析記事を書いていた。

Octoverseレポート特別編:COVID-19影響によるリモートワーク環境でのソフトウェア開発者生産性に関する分析 - GitHubブログ

The DevOps Handbook

Accelerateの参考文献からたどって読んだ書籍。邦題は『The DevOps ハンドブック 理論・原則・実践のすべて』

The DevOpsシリーズはいくつか本があって、これは2作目らしい。1作目はDevOpsをテーマにした小説っぽい感じ"The Phoenix Project"*4だがこちらは未読。

DevOpsは2009年から始まったムーブメントで、私はその当時一介の学生だったので、業界のなかでそのムーブメントを体験したわけではないが、そのときに出てきた数々のプラクティスのうちいくつかは実際にいまも取り組んでいるものであった。しかし、この本で説明されているように

  1. バリューストリームのフローを速くする
    • カンバン、アンドン、WIP制限、独力でバリューストリームを完遂できる職種横断(devとops)で市場指向な組織編成、デプロイパイプライン、自動テスト、トランクベース開発
  2. バリューストリームの末端から先頭へフィードバックが回るようにする
    • 本番環境やデプロイパイプラインからのメトリクス収集、問題発生時にチームですぐに復旧に取り組みMTTRの短縮、デベロッパーのオンコール参加、コードレビュー依頼時のバッチサイズの小ささの意識
  3. バリューストリームの途中でも細かくフィードバックが得られるようにする
    • 一部門の発見を共有リポジトリなどを通じて全社に広める、セキュリティや品質に関するメトリクスをバリューストリームの前段階へ、技術的に社内統制の課題を解決することでDevOpsと統一

という三つの観点でそれぞれのプラクティスが存在している、という背景まで踏み込んで考えたことはなかった。背景を含めて体系的に説明されていて、それなりに長い本だが考えが整理されて役に立った。あと、このあたりの話はトヨタ生産方式に端を発するリーン運動の話も多く出てくるので、そちらに関するテーマの本も読んでみたいところ。

Bliki

Bliki*5はMartin Fowler氏のWebサイトで公開されているソフトウェア開発プラクティスのシリーズ記事…という認識で合っているのだろうか*6

最近公開されていた記事2件がAccelerateやDevOps Handbookでの内容とも関連していると思ったので読んだ。

Pull Request

PullRequest

GitHubやGitLabが備えるpull request/merge request(以降"PR"とする)という機能はソフトウェア開発に欠かせないものとなっているが、PRをデフォルトと思い込んでいないか?ということを問うている記事のように感じた。主に次のようなことが書かれている。

  • 本来の意味での継続的インテグレーション、つまりできるだけトランクベースで開発するためにPRを使うなら、十分に小さいPRにしよう
  • 毎日、個人がメインラインに1回は統合できるようにチームとしてPRに反応していこう
  • PRだけがコードレビューの場ではないことを思い出そう

PRだけがコードレビューの場ではないという点については、例えばペアプログラミングでナビゲータ側が継続的にコードをレビューすることや、refinement code reviewという手法について言及している。refinement code reviewは次で説明する。

Refinement Code Review

RefinementCodeReview

おおまかな内容としては次のとおり。

  • ソフトウェアは建築で例えられてきたが、どちらかというと都市計画ではないか
    • by Erik Dörnenburg
    • ソフトウェアの価値に対する理解が深まるにつれて変化し続ける
  • 例えば開発中に一見してよくわからないコードを理解したら、その理解をコードに反映させる責任がある
    • by Ward Cunningham
    • あとの人が困らない
  • コードを読むたびにコードレビューが発動するようなものである
  • PRでのコードレビューだけでは、PRで入れる変更が既存コードベースで今後どのような状態になっていくかを見逃してしまう

これができればメリットは多そうと思うが、既存の開発フローにどう組み込めそうかについては、すぐにいい解は思いつかない…。このプラクティスにはテストコード、継続的インテグレーション、リファクタリング、(弱い)コードの所有権という4点が必要とのこと。

所感

これまでコードをどう書くかということばかり考えていて、メタな視点でソフトウェア開発を捉えることはあまりしてこなかったが、いろいろなリソースを読んで少し慣れてきた。"Accelerate"ですら、まだまだ消化し切れていないところがあるが、ほかにも『継続的デリバリー』なども読んでみようと思う。

あと、いろいろ読んだ結果、どうやるのがよしとされているかはなんとなくわかったが、それが自分の頭の中にしかないので、理想像を描いてチームメンバーに見てもらったり、一緒に実践していくなどの行動を起こす必要があると常々感じている。

*1:個人の感想

*2:Backstage Service Catalog and Developer Platform · An open platform for building developer portals

*3:組織の保持する能力

*4:邦題が『The DevOps 逆転だ!究極の継続的デリバリー』…

*5:本来はWiki機能をあわせ持ったブログのことを指すようだが、少なくともFowler氏のサイトではそのような機能はなさそう

*6:日本語版もある

不安定なテストが存在することをSlackに通知するGitHub Actionsワークフロー

あるGitHubリポジトリのmaster(や最近はmain)ブランチで確率的に落ちるテストは間違いなく不安定なテスト*1であるという考えのもと、不安定なテストを見つけたときに自動でSlackに通知するGitHub Actionsワークフローの書きかたについて説明する*2

なお、この記事ではテスト自体の書きかたの良し悪しについては言及しない。

方法

  • 不安定なテストを通知するジョブは、テストを実行するジョブの実行結果に依存する
  • 現在のブランチがmaster、かつテストの終了ステータスが正常でないとき通知する
    • 今回はテストが1件でも失敗したなら、テストのコマンドは終了ステータスとして1を返すとする

これをYAMLに落とし込むとこうなる*3

test:
  # リポジトリのテストを実行

flaky-test-notification:
  needs:
    - test
  runs-on: ubuntu-latest
  if: always()
  steps:
    - name: Notify flaky tests
      if: github.ref == 'refs/heads/master' && needs.test.result == 'failure'
      uses: tokorom/action-slack-incoming-webhook@main
      env:
        INCOMING_WEBHOOK_URL: {{ secrets.WEBHOOK_URL }}
      with:
        text: 'Flaky tests found: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}'

まず、testジョブの結果に依存するのでneedstestを指定する。しかし、needsに指定したtestジョブはテストに失敗したとき終了ステータスが1なので失敗とみなされる。このときneedsを持つジョブはデフォルトでスキップされてしまう*4。この場合もflaky-test-notificationは実行したいので、ステップ自体はつねに実行されるようにif: always()を指定する。

通知用のステップで現在mainブランチかつテストが失敗しているか(すなわち不安定なテストが見つかったか)をgithub.refneeds.test.resultで判定する。判定がtrueならSlackへ通知する。GitHubフローで開発しているなら、masterブランチへはpull requestのマージでコミットが追加されるはずであり、それはpushと見なされるので、flaky-test-notificationはpull requestマージ時に1回だけ実行される。

Slackへ通知する既存のActionはいくつか存在するので何かを使えばいいが、ここではtokorom/action-slack-incoming-webhookを使わせていただく。といっても、リポジトリのActions用シークレット*5にSlackのIncoming Webhook URLを登録しておき、送信するテキストを設定すれば終わり。ここではgithub.repositorygithub.run_idからGitHub Actionsの実行画面のURLを作成して、Slackからすぐに結果を確認しに行けるようにしている。

不安定なテストが見つかると次のようにSlackに通知が来る。

f:id:kymmt90:20210216230146p:plain

このあとは粛々とテストを直しましょう。

*1:flaky testsともいう https://docs.gitlab.com/ee/development/testing_guide/flaky_tests.html

*2:業務で必要に駆られて作成しチームメンバーにレビューしてもらった

*3:本当はGitHub Enterprise Serverで作ったものをgithub.com風に書き換えた

*4:"If a job fails, all jobs that need it are skipped unless the jobs use a conditional expression that causes the job to continue"

*5:リポジトリ配下の/settings/secrets/actionsから設定できる

2020年まとめ

2020年は特異な年だったというのもあり、個人的なことも含めて2020年にあったことをまとめた。

仕事

リモートワーク

2020年1月15日に日本での新型コロナウイルス感染者が確認されたあと、国内ではかなり早い段階で所属企業を含むグループ全社でリモートワークが始まった。その開始をなぜかTwitterを通じて知り、「報道で知る」という感覚を初体験した。

結局ペパボはリモートワークを原則とする勤務体制に変更された。会社関連の用事で渋谷に行ったのは3回だけだった*1

ずっと家にいるので、仕事用の机のまわりの設備を改善した。とはいえ、基本的にMacBook Pro1台だけで作業することにしているので、充電器と椅子とデスクライトを買った程度。

定額給付金はBaronに充てさせてもらった*2。会社の椅子がBaronだったのでそれに合わせた。シートタイプはクッションにした。

仕事の能率を上げる方法として古典的だがポモドーロテクニックを試していた。

ポモドーロ・テクニックを2か月間やってみての感想 - kymmt

いったん原典のとおりに厳密にやっていて、新規に開発しているときなどはハマっている感じがしてよかった。調査業などとはどうしても相性が悪い感じがしている。最近はタスクリストの書き出しとポモドーロの計測だけという緩い運用になっていることが多い。

マンション自体の作りや立地の問題で、自分にとっていまの家はリモートワークに向いていないと感じている。そのわりに家賃がやたらと高く、来年で契約更新になるという事情もあるので、2021年の上期には引越したいと考えている。

業務

サービス基盤チーム所属という肩書きで、Eコマースプラットフォーム*3のWebアプリケーションレイヤを全般的に改善するという役回りだった。所属しているチームの概要についてはhrysdがスライドにまとめてくれている

主にサービスにおける事業者側の玄関口にあたる認証/認可部分のセキュリティ向上や新しいアーキテクチャへの移行に取り組みつつ、Webアプリケーションの細かい改善をやっていた。また、コロナ禍でEコマースの需要が高まったことを背景に、一時的ではあるが久しぶりに新機能をチーム開発したりもした。

15年の歴史を持つサービスの認証/認可の設計改善についてはなかなか進めづらいものだが、今年はセキュリティ向上の作業に抱き合わせることで(?)よりよいアーキテクチャに移行していけそうに見えたので、理想とするアーキテクチャの案を上長や各ロールを担当するエンジニアと共有し、みんなで協力して今年やるべき分については開発を進められたのでよかった。しかし、この分野はとにかく気を使うので大変と感じる。OAuth 2.0の認可サーバの実装だけでも、ライブラリを入れるだけではなくSecurity Best Current PracticeをはじめとするRFCを読んだり情報収集したうえで、適切に実装するのが必須であるということを身を持って感じた。令和の時代に新規にWebアプリケーションを作るなら認証/認可は原則IDaaSに任せるのがいいのだろうなという気持ちになっている。

また、下期はWebアプリケーションを運用し続けると溜まる問題(省みられないエラー通知、古いライブラリのバージョンアップなど)の解決に発生しがちな属人性を減らして、チームや事業部レベルで解決できるように進めかたを考えて、まずはチームで実践したりしていた。ここは道半ばなので引き続き。

アウトプット

記事

個人ブログの記事は14件。だいたい月末になにか書くことがないか考えて書くという感じで、テーマがバラバラだし内容も未来の自分向けが多い。そういえば年初はHaskellを勉強していたけど、すっかり触らなくなってしまった。

2020-01-01から1年間の記事一覧 - kymmt

会社のブログにWebアプリケーションのセッション管理に関する記事を書いた。会社の名前を借りてWebに情報発信させてもらえるということもあり、個人ブログよりはよっぽど読まれたようだった。

SPA+SSR+APIで構成したWebアプリケーションのセッション管理 - ペパボテックブログ

その他はQiitaにtipsレベルの記事を数本書いたぐらい。

OSS

自分のOSSにもらったPRを対応したり、業務の関係上doorkeeper-gemのgemをよく読んだり使ったりしていたので、そのあたりで少しだけ貢献した。

仕事の関係で小さいgemを作成した。

総じてOSSをバリバリやる感じではなかった。

その他

読書

79冊読んでいた*4

Yamamoto Kōheiさんの読書記録

技術書はOAuth/OIDC関連の本が多め。Auth屋さんの本には助けられた。なんとなく量子コンピュータが気になって関連する本を数冊読んだが、手を動かさずに終わっている。

6月ぐらいにKindleでちくま新書のセールをやっていたときに畑違いの分野の本をここぞとばかりに買って読んでいた。また、Kindle Oasisを買って一般書の読書体験はかなり向上した。電子ペーパーのほうが圧倒的に紙に近く読みやすいし、片手でそれなりの画面サイズのデバイスを持って読み進められるのが個人的に体験がよく感じる。

Emacs

Emacsを使うのをやめて、Visual Studio Codeだけで開発するようになった。また、これまで雑にメモを書いたり一時的に文字列を置きたいときはEmacsの*scratch*バッファを使っていたが、いまはSpotlightでTextEdit.appを立ち上げて用を済ませている。

Emacsとの出会いは大学に入った2008年4月*5で、本格的に使い始めたのは研究室に入ってまともにプログラミングをし始めた2011年〜2012年の間だと思う*6。当時『Emacs実践入門』の初版が出たころで、開発環境整備の重要性はこの本から教わったように思う。その後、時代は移り変わり、VS Code主流の世の中でも惰性でEmacsを使っていたが、リモートワーク下での開発作業でVS CodeのLive Shareを使いたい場面があり、そのために自分がボトルネックになるのはよくないので、いい機会と思って移行してしまった。この記事もVS Codeで書いている。

Awesome Emacs Keymapを使っているので、まだ脱Emacsし切っているとはいえないが、手元のマシンからはEmacsをアンインストールした。

Try to quit using Emacs for a time · kymmt90/dotfiles@85ca748

健康

通勤がなくなったので、朝の散歩と軽い自重トレーニングは毎日やっている。一念発起して、花粉症の治療のために6月ごろからシダキュアを服用しはじめた。月1回通院があるが散歩だと思って通っている。シダキュアはHabitifyで見る限り202日連続服用できていて挫折はしていない。

悪いできごととしては口腔外科系の病気になってしまったので、来年の医療費がどうなるか読めず不安感がある。また、生活環境が変わった影響か息抜きがうまくできておらず、そのわりに精神的に焦るだけで何もできていないという自覚があるので、来年は改善したい。

所感

総じて大変な年だった。まだまだやれていないことや知らないことだらけなので来年もがんばりたい。


*1:荷物の回収、感染拡大が落ち着いていた時期に久々にチームで顔合わせ、インフル予防接種

*2:14万円弱したので足は出ている

*3:https://shop-pro.jp / https://colorme-repeat.jp

*4:漫画を含む。再読した本が計算に入ってなさそうで、実際は80冊は超えていると思う

*5:大学の演習室にあったのはLeopardの入ったMac Proで、演習の授業の初回でEmacsが導入された

*6:研究室の支給PCはWindows 7が入っていたので、最初は雰囲気でxyzzyを使っていた。そのあとNTEmacsに移行した

Railsエンジンのappディレクトリ配下のクラスを親アプリでオーバーライドする

やりたいこと

Railsエンジンのappディレクトリ配下に存在するクラス(モデルやコントローラ)のメソッドをオーバーライドしたい。

結論

RailsガイドのRailsエンジンについての記事に全部書いてある。Railsエンジンのapp配下のオーバーライドは、to_prepareを使って、親アプリの初期化が終わったあとに実行する。オーバーライドするクラスはclass_evalでリオープンする。

# config/application.rb
module TestApp
  class Application < Rails::Application
    # ...

    # アプリの初期化が終わったときに呼ばれるフック
    config.to_prepare do
      # もしZeitwerkなら`require_dependency`が非推奨なので`load`を使う
      require_dependency Rails.root.join('lib/monkey_patch/foo_bar_engine.rb')
    end
  end
end
# lib/monkey_patch/foo_bar_engine.rb
module MonkeyPatch
  module FooBarEngine
    def do_something
      # 上書きする
    end
  end
end

FooBarEngine::FooBarsController.class_eval do
  prepend ::MonkeyPatch::FooBarEngine
end

詳細

Railsガイドを読めば問題は解決するのだが、思いつく他の方法で試してみて、なぜだめだったかを見てみる。

オーバーライドに失敗する例: その1

親アプリ側のディレクトリに次のようなコードを書くと、このアクションに対応するエンドポイントにリクエストを送るとき、AbstractController::ActionNotFoundのエラーになる。

# app/controllers/foo_bar_engine/foo_bars_controller.rb
module MonkeyPatch
  module FooBarEngine
    def do_something
      # 上書きする
    end
  end
end

module FooBarEngine
  class FooBarsController
    prepend ::MonkeyPatch::FooBarEngine
  end
end

開発環境では、定数参照時にconst_missingになるとActive Supportがautoload_pathsからパスの規約などに基づいて定数を探す。autoload_pathsは例えば次のように確認できる:

[1] pry(main)> puts ActiveSupport::Dependencies.autoload_paths
/usr/src/app/app/assets
/usr/src/app/app/controllers
/usr/src/app/app/controllers/concerns
/usr/src/app/app/helpers
/usr/src/app/app/jobs
/usr/src/app/app/mailers
/usr/src/app/app/models
/usr/src/app/app/models/concerns
/usr/local/bundle/gems/letter_opener_web-1.4.0/app/assets
/usr/local/bundle/gems/letter_opener_web-1.4.0/app/controllers
/usr/local/bundle/gems/letter_opener_web-1.4.0/app/models
/usr/local/bundle/gems/devise-4.7.3/app/controllers
/usr/local/bundle/gems/devise-4.7.3/app/helpers
/usr/local/bundle/gems/devise-4.7.3/app/mailers
/usr/src/app/spec/mailers/previews
=> nil

ここではletter_opener_webやdeviseなどのRailsエンジンのapp配下もautoload_pathsの後ろのほうに入っている。

FooBarsControllerを読み込んでいないとき、アクション実行時にFooBarEngine::FooBarsControllerという定数を解決することになる。autoload_pathsに従うとRailsエンジンより先に親アプリの定義を見てしまい、中身がほぼ空のコントローラのアクションを呼び出してしまってAbstractController::ActionNotFoundになる。

オーバーライドに失敗する例: その2

config/application.rbの末尾でclass_evalでオーバーライド対象のクラスをリオープンしてオーバーライド用のモジュールをprependすると、uninitialized constant FooBarEngine::FooBarsController (NameError)のエラーになる。

# config/application.rb
module TestApp
  class Application < Rails::Application
    # ...

    # これがないとlibにパスが通らない
    config.eager_load_paths << "#{Rails.root}/lib"
  end
end

require_dependency 'lib/monkey_path/foo_bar_engine.rb'
# lib/monkey_path/foo_bar_engine.rb
module MonkeyPatch
  module FooBarEngine
    def do_something
      # オーバーライドする
    end
  end
end

FooBarEngine::FooBarsController.class_eval # この定数が見つからない
  prepend ::MonkeyPatch::FooBarEngine
end

これは、config/environment.rbでconfig/application.rbを読み込んだ時点ではアプリの初期化が終わっておらず、オートロードの準備もできていないので、Railsエンジン配下の定数を探索できないのが理由。

オーバーライドに成功する例

結論に書いたとおり、config.prepare_toフックでオーバーライドする。prepare_toはアプリの初期化が終わった時点で呼び出されるので、オートロードも可能であり、Railsエンジン配下の定数を探索することもできる。

authorization code grantに沿ったDoorkeeperのコードリーディング

さまざまな都合により、OAuth 2のプロバイダになるためのDoorkeeperというgemのコードを読むことがしばしばある生活を送っている。

似た名前のモジュールやクラスが多く、読むたびに混乱しているので、authorization code grantでアクセストークンを取得するときの登場するクラス/モジュールと流れをあらためて自分なりに整理した。基本的に自分用であって、網羅的ではない。

前提

2020-11-28現在での最新版であるDoorkeeper 5.5.0.rc1を読む。authorization code grantが正常に通るときのパスだけを見る。

RailsのAPIモードは無効とし、Doorkeeperの設定resource_owner_authenticatorで渡すブロックでは特定のリソースオーナーの認証に常に成功しているとする。本来は認証を実際に実行し、失敗すれば再認証させるべき。

以降の文章では、Doorkeeperが提供する名前空間のうちDoorkeeperDと省略する。

Doorkeeper用エンドポイントの登録

DoorkeeperはRails Engineであり、ルーティングを拡張するためのuse_doorkeeperというメソッドが提供されている。このメソッドでルーティングを拡張するまでの流れは次のとおり。

主に登場するクラス/モジュール

名前 概要
D::Engine Rails EngineとしてRailtieのinitializerを設定する
D::Rails::AbstractRouter Doorkeeper用ルーティング拡張クラスのためのインタフェースを表す
D::Rails::Routes 親アプリのルーティングにDoorkeeper用のエンドポイントを追加するメソッドを持つ

ルーティングの設定フロー

  • D::Engine"doorkeeper.routes"としてinitializerを登録する
    • 親アプリの初期化時にD::Rails::Routes.install!を実行する
      • ActionDispatch::Routing::Mapperuse_doorkeeperをincludeすることでルーティングの設定でuse_doorkeeperできるようにする
  • Doorkeeper利用時に親アプリのconfig/routes.rbでuse_doorkeeperする
    • D::Rails::Routes#generate_routes!を実行する
    • Railsのscopeを呼び出して、その中で D::Rails::AbstractRouter#map_routeによってDoorkeeperのエンドポイントを定義する
      • D::Rails::Routesのprivateメソッドで個々のルーティングが定義されており、それらのメソッドをsendで呼び出している

authorization code grantの認可リクエスト

authorization code grantでは、あるクライアントとして認可リクエストを送り認可コードを得る必要がある。

主に登場するクラス/モジュール

D::RequestD::OAuthそれぞれの配下に似たような名前のモジュールやクラスがあって混乱する。

コントローラ関連

名前 概要
D::AuthorizationsController /oauth/authorizeへのリクエストがルーティングされるコントローラ
D::Helpers::Controller Doorkeeperの設定をもとにした値などを取得するためのメソッドが集められたモジュール

認可リクエスト

名前 概要
D::OAuth::PreAuthorization 認可リクエストのパラメータのラッパークラス。バリデーションを実行したりscope文字列をパースする
D::Validations D::OAuth::PreAuthorizationD::OAuth::BaseRequestでのバリデーションの仕組みを提供するモジュール
D::Models::AccessTokenMixin アクセストークンに関するロジックを提供するモジュール。ORマッパーへの依存を減らすために、アクセストークンのモデルからは切り離されている
D::OAuth::Hooks::Context 認可前後のフック関数に渡すコンテキストを表すクラス
D::Server 認可サーバとして必要なリクエスト、パラメータ、現在のリソースオーナーやクライアントへアクセスするためのメソッドを提供するクラス。コントローラをコンテキストとして渡して使う
D::Request response_typeを渡して、対応する認可/トークンリクエストを処理するストラテジクラスを返すためのメソッドを提供するクラス
D::Request::Strategy リクエストをもとに認可するストラテジクラスの基底クラス。#authorizeというメソッドを提供する
D::Request::Code D::Request::Strategyを継承するauthorization code grantのストラテジ。#requestではD::OAuth::CodeRequestはインスタンスを返す。D::Request::Strategy#authorizeを呼ぶと、そのインスタンスに#authorizeを委譲する
D::OAuth::CodeRequest 認可コードをD::OAuth::Authorization::Codeのインスタンスとして生成して、認可エンドポイントのレスポンスを作成する
D::OAuth::Authorization::Code 認可コードのラッパークラス。認可コードを発行しグラントを記録するテーブルへ保存する#issue_token!を提供する
D::OAuth::CodeResponse 認可エンドポイントのレスポンスのラッパークラス。コールバックまたはネイティブアプリ向けの方法で認可コードをクライアントに渡すために必要なデータを提供する
D::GrantFlow D::GrantFlow::RegistryにOAuthのグラントの種類とDoorkeeperのストラテジークラスの対応を登録するモジュール
D::GrantFlow::Flow D::GrantFlowで登録する対応を表すクラス

承諾画面の表示

GET /oauth/authorizeを呼び出すときの流れ。

まず、リソースオーナーのデータを取得する。

  • D::AuthorizationsController#newへルーティングする
  • before_action :authenticate_resource_owner!を実行する
  • D::Helpers::Controller#current_resource_ownerを実行する
  • 親アプリのconfig/initializers/doorkeeper.rbなどでD.configureで設定する authenticate_resource_ownerのブロックを呼び出し、返り値を@current_resource_ownerへ入れる

次に、認可エンドポイントへのリクエストを検証する。

  • D::AuthorizationsController#new#pre_authを呼び出す
    • Doorkeeperの設定、認可リクエストのパラメータ、@current_resource_ownerをもとにD::OAuth::PreAuthorizationのインスタンスを作る
  • pre_auth#authorizable?を実行する
    • D::Validations#validateを実行する
      • あらかじめD::OAuth::PreAuthorizationの序盤で定義しているvalidate :client_id, ...などはこのモジュールのメソッドであり、バリデーションを登録している
      • 登録されたバリデーションを順番に実行する
        • バリデーションメソッドはvalidate_#{属性名}sendする
        • これらもD::OAuth::PreAuthorizationにあらかじめ定義してある
        • それぞれのバリデーションはOAuth 2に基づいたもの

リクエストが妥当であれば、クライアントへ承諾画面を返す。

  • authorizable?であればrender_successを実行する
    • D::Helpers::Controller#skip_authorizationを実行する
      • 認可スコープの承諾画面を表示するか否かを決める
      • Doorkeeperの設定のskip_authorizationのブロックを実行する
    • #matching_tokenを実行する
      • D::Models::AccessTokenMixin#matching_token_forですでに対象のクライアントとリソースオーナーの組み合わせで作成済みのアクセストークンを探す
    • #skip_authorization#matching_tokenのどちらかがtrueなら、すぐにauthorize_responseで作成する認可済みのレスポンスを返す
    • そうでなければ:newをレンダリングする
      • app/views/doorkeeper/authorizations/new.html.erbをレンダリングする
      • リクエストしているスコープを表示し、承諾もしくは拒否を求める画面

認可コードの発行

承諾画面で承諾をサブミットし、POST /oauth/authorizeを呼び出すときの流れ。

認可コードを生成する。

  • D::AuthorizationsController#createにルーティングする
  • #authorization_responseを呼び出す
    • pre_authをもとにD::OAuth::Hooks::Contextのインスタンスを作る
    • フックbefore_successful_authorizationを実行する
    • #strategyを呼び出す
      • #serverを呼び出す
        • D::Helpers::Controller#serverを呼び出す
        • コントローラ自身をcontextとして渡してインスタンスを作る
      • Server#authorization_requestを呼び出す
        • 引数に pre_auth.response_typeを渡す。いまは"code"
        • D::Request.authorization_strategyから"code"に対応する認可strategyクラスを取得する
          • D::GrantFlowで各グラントのstrategyクラスなどは設定済み
          • Doorkeeperの設定にあるgrantから対応するものをD::GrantFlow::Flowとして取り出す
          • request_type_strategyを呼び出してD::Request::Codeを返す
          • D::Request::Codeにserverを渡してストラテジーオブジェクトを作る
      • strategyとしてD::Request::Codeのオブジェクトが得られた
    • strategyであるD::Request::Code#authorizeを実行する
      • D::Request::Strategy#authorize#requestD::OAuth::CodeRequest#authorizeと委譲される
      • pre_authresource_ownerを引数に取ってD::OAuth::CodeRequestのインスタンスを作る
      • D::OAuth::CodeRequest#authorizeD::OAuth::Authorization::Codeのインスタンスを作り#issue_token!を呼び出す
        • 認可コードを生成して、既定のActive Recordモデルを通じてテーブルに保存する
      • D::OAuth::CodeResponseのインスタンスをpre_authD::OAuth::Authorization::Codeのインスタンスを渡して作り、returnする

認可コードをコールバックURIに付与するかネイティブアプリ用画面のURIのパラメータとして返す。

  • 認可コードを返すためにredirect_or_render authorization_responseする
    • D::OAuth::CodeResponseのインスタンスであるauthorization_responseredirectable?なら、そのURIへリダイレクトする
    • 認可コードのときは常にtrueなので、oobであればoob用のURIに、それ以外は設定済みのURIに、認可コードをパラメータとして付けてリダイレクトする
      • oobのときD::OAuth::Authorization::Code#oob_redirectをもとにリダイレクトし、app/views/doorkeeper/authorizations/show.html.erbをレンダリングする

トークンエンドポイント

主に登場するクラス/モジュール

認可エンドポイントで登場したものは省略。

名前 概要
D::TokensController /oauth/token へのリクエストがルーティングされるコントローラ
D::Request::AuthorizationCode D::Request::Strategyを継承するauthorization code grantのストラテジ。#authorizeを提供する。Strategyでの#authorize#requestへの委譲時にD::OAuth::AuthorizationCodeRequestを生成する。そのときに#grantの呼び出しを通じて認可コードの検証を実行する
D::OAuth::BaseRequest トークンエンドポイントへのリクエストの基底クラス。#authorizeでトークンレスポンスの生成と前後のフックの実行を提供する
D::OAuth::AuthorizationCodeRequest authorization code grantでのトークンエンドポイントへのリクエストを表すクラス。PKCEのchallengeの検証も担う。フックD::BaseRequest#before_successful_responseをオーバーライドしてアクセストークンを作成している
D::Models::AccessGrantMixin アクセスグラントに関するロジックを提供するモジュール。ORマッパーへの依存を減らすために、アクセスグラントのモデルからは切り離されている
D::OAuth::TokenResponse トークンエンドポイントのレスポンスのラッパークラス。ステータスコードやレスポンスのJSONを取得できる

アクセストークン取得の流れ

POST /oauth/tokenを呼び出して、アクセストークンを含むJSONをレスポンスとして得る。

  • D::TokensController#createにルーティングする
  • #authorize_responseを呼び出す
    • 認可エンドポイントと同じ流れでserverを取得しD::Server#token_requestを呼び出す
      • 引数としてparams[:grant_type]を渡すが、ここでは"authorization_code"
      • D::Request.token_strategyであらかじめ登録済みのstrategyからgrant_type_strategyとしてD::Request::AuthorizationCodeを取得してreturnする
      • そのクラスのインスタンスを得る
    • D::Request::AuthorizationCode#authorizeを呼び出す
      • D::Request::Strategy#authorize#requestD::OAuth::AuthorizationCodeRequest#authorizeD::OAuth::BaseRequest#authorizeと委譲される
      • D::Request::AuthorizationCode#requestgrant呼び出し時に認可コードをもとにoauth_access_grantsのレコードを探している
        • D::Models::AccessGrantMixins.by_tokenで実行している
      • D::OAuth::BaseRequestD::OAuth::ValidationsをincludeしているのでD::OAuth::PreAuthorizationと同じく宣言済みのバリデーションをvalid?で実行できる
      • valid?ならトークンレスポンスを返す
        • D::OAuth::AuthorizationCodeRequest#before_successful_responseで認可コードをrevokeしてアクセストークンを生成する
        • D::OAuth::TokenResponseにアクセストークンを渡してインスタンスを作る
      • トークンレスポンスのオブジェクトをreturnする
  • D::TokensController#createでトークンレスポンスから#body#statusでトークンレスポンスのJSONのステータスを取得してrenderする

所感

DoorkeeperはOAuthの各グラントに対応し、またORマッパー非依存になるような設計で作られていて、さまざまな要件のもとでOAuth 2サーバを作りたいという希望にかなうライブラリとなっている。そのぶん、やっていることが複雑であったりもするし、細かいカスタマイズを施したくなる場面もたびたびある。また、認可という場合によってはクリティカルな機能に関わるライブラリでもある。そういう点で、ただのブラックボックスとして扱うよりは、できるだけ内部を知っておいたほうがいいだろうと思う。どのライブラリにも言えることではあるが、アプリケーション開発の延長として、ライブラリの新バージョンリリース時などのタイミングでこまめにコードを読むことを継続していく。