N予備校バックエンドでサーバーサイドKotlin移行を始めました

はじめに

現在、N予備校バックエンドチームでは、現行のRails製アプリケーションからKotlin製の新アプリケーションへ一部移行する計画を始めました。

N予備校サービス構成図

移行の主な対象は、上記の図の紫の部分の 教材管理サービス まわりになります。

移行の目的

今回の移行は、主に次の2つの問題の解決を目指しています。

DBスキーマを含めたモデルの再設計

N予備校はサービスの仕様が十分に固まらないうちから基本設計が開始されたため、必要以上の柔軟性を持つ形で設計されている部分も多く、現在の事業ドメインの知識がモデルで十分に表現されているとは言い難い面があります。

特にコンテンツデータ同士の参照関係については、階層構造の大きな変化1にも対応できるように、多くがActiveRecordのポリモーフィック関連を使って実装され、コードやDBスキーマからデータ構造を読み取るのが非常に難しくなっています。

さらにサービス開始後には、N高等学校の高校教育課程対応という大きな機能追加がありました。 しかし、その開発は十分とは言えない開発期間に間に合わせるために、設計や機能の統合が不十分な状態で実装されたという歴史があります。

例えば、高校教育課程対応の必修コースとそれ以外の課外コースで、同じようなコードを二度実装している個所が多く、そのため、必修コースでは対応しているが課外コースでは非対応な機能 (またはその逆)が散見されます。

また、教育事業では昨年から、データ分析の活用をはじめとしたいくつかの新しい取り組みが開始されていますが、サービス開始当初の想定に無かった新たな視点での開発に対して、現状のモデル設計では困難を覚えることが増えたと感じます。

現在直面している課題としては、例えば、問題採点サービス のデータ体系がその他教材のデータ体系から完全に独立していることで、生徒の解答記録とその他のデータをつきあわせた総合的な分析に困難が発生していることが挙げられます。

この先にはさらに大きな計画も控えており、これらをスムーズに成し遂げてその先の継続的な仕様追加に耐え得るコードベースを作るためには、DB設計も含めた一からのモデルの再検討が必要だと考えました。

サービス境界の見直し

こちらの記事で以前紹介したように、N予備校のバックエンドではマイクロサービスアーキテクチャを採用しています。 しかし、各サービスに対して専任のチームを用意するための人的リソースが足りないので、全員が全部のサービスを担当するような組織構造です。2

ただし、実際の開発対象は API Gateway教材管理サービス に集中しており、メンバーがそれ以外のサービスに開発の手を入れることはほとんどありません。 その結果、新規メンバーがこれら2つのサービス以外についての知識を得る機会が乏しく、メイン2つ以外のサービスに関しては開発当時のメンバーに依存してしまっているところが大きくなってしまっています。

また、一部のサービス境界については、責務分割があまり上手くできていないと感じるところがあります。 例えば生授業の情報については、 教材管理サービス授業管理サービス で一部重複して管理されていますし、認可を含めた教材の公開判定については 教材管理サービスAPI Gateway が分担して行っている形になっています。

さらに、チーム数よりも多くのサービスを抱えることで、言語やフレームワークなどのアップデート作業とそれに伴う動作確認が必要以上に大きな負担になってしまっているとも感じています。

移行後のサービスではこれらのマイクロサービスを一部統合しモジュラモノリスに近づけていくことで全体の見通しを良くし、これらの問題の改善を図っていこうと考えています。

なぜKotlinを採用したのか

新しいシステムを書く言語としては、以下のような理由からKotlinを採用するに決めました。

null安全な静的型付言語

サービス開始時から積み重ねられてきた大規模コードベースでは、読むにも書くにも多くの予備知識が必要となるので、静的型付言語によるコンパイラのサポートを取り入れることにしました。 特に、型に関するエラーの多くはnullまわりで起こると感じていたので、null安全な言語であることを重要な選定基準にしていました。

関数定義の引数や戻り値に型注釈がつくことで型に多くの意味を込めることが可能になり、コードを読む際の大きな助けになります。 また、型やメソッド名等の不一致については全てコンパイル時に静的にチェックされ、関数の引数チェックやテストコードの動作確認において考慮するべき事象を著しく減らすことができるので、コードを書くときの生産性の向上にもつながります。

さらにKotlinでは value classsealed type などの型の表現力を上げる言語仕様が豊富に用意されており、これらはドメイン知識をコードとして表現する際の大きな助けになっています。

Rubyに近い書き心地、読み心地

元のシステムがRails中心であることから、現状でチームでもっとも馴染みのある言語になっています。 そのため、そこからの学習コストが高すぎる言語は候補から外れました。

例えば、Rustは言語仕様的には魅力を感じる部分も多かったのですが、既存のOOP言語とは異なる概念が多く採用されていて、習得に著しい困難が予想されたので、採用は断念しました。

また、Goは言語仕様的には単純で学習コストも低いと考えられますが、愚直なコードを奨励するスタイルがRubyとは真逆の方針であるため、Rubyに慣れた人間にはストレスが大きいだろうと考えました。

internal宣言とgradleマルチプロジェクトによるモジュール管理

先述のように、新システムではモジュラモノリス的な構成を目指しますが、そのためにはモジュールという構成単位を効率的に管理することが重要になります。

Kotlinでは、public, private のようなオブジェクト指向で一般的なクラス単位の可視性修飾の他に internal の可視性修飾子が用意されています。 これをgradleのマルチプロジェクトと組み合わせることで、「モジュール内の他クラスからは見えるが別モジュールからは見えないクラスや関数」を簡単に実現することが可能です。

また、あるgradleプロジェクトが他のどのプロジェクトに参照できるかは、build.gradle 内で明示的に設定されるので、モジュール同士の依存関係を厳密に管理することができます。

アーキテクチャ

JVMによるサーバーサイドの開発が初めての経験なので全て手探りの状態ですが、新システムは以下のようなアプリケーションアーキテクチャで開発中です。

レイヤー分割

アーキテクチャレイヤーの概念図

新システムは、ドメイン層レポジトリ層アプリケーション層 の単純な三層構造で設計しています。

各層は上記の順番で前に位置する層にのみ依存するようにし、各層は以下のような簡単な定義になっています。

  • ドメイン層: DBにもフレームワークにも依存しない、純粋なビジネスロジックを書くところ。
  • レポジトリ層: DBなどのデータの永続化や外部サービスからのデータ取得のコードを書くところ。ORMへの依存はこの層に閉じ込める。
  • アプリケーション層: 利用するウェブアプリケーションフレームワーク(WAF)や、サービスのリクエスト・レスポンス形式に依存したコードを書くところ。いわゆるユースケース層はここに含める。

また、gradleプロジェクト内のテストコードは他のプロジェクトから参照することができないので、複数のモジュールで共通で使うための テストヘルパー層 も別途設けています。 ここには例えば、各種id系クラスのダミー値や、デフォルト値でテストデータを用意するための関数などが用意されています。

データベース

今回の移行ではDBスキーマのレベルでモデルの見直しを行いますが、移行後のスキーマは全て1つにまとめられる予定です。

モジュール単位でスキーマを分割することも検討しましたが、今回の移行対象は全て教材の配信や学習記録、登録に関係するものであり、データの整合性を保証した形でのうまい分割境界が見つかりませんでした。

ただし、この先新システムで取り扱う範囲が増えて教材とは直接関係しないデータも扱うようになったら、その時は別のDBスキーマを立てようと考えています。

複数アプリのモノレポ構成

gradleのマルチプロジェクトで複数のビルド単位を持てるようになっているため、利用コンテキストや非機能要件の違いに応じて複数のアプリケーションを1つのレポジトリの中に作れるようにしようと考えています。

具体的には、生徒が学習する際に利用するAPIとコンテンツ登録に使うAPIを別々のアプリケーションにしたり、試験期間中のデプロイが大きく制限される高校単位認定試験用のAPIを分けたりする形です。

ディレクトリ構造

上記を踏まえて、新システムのレポジトリはこのようなディレクトリ構造になっています。(モジュール名は適当に変えてあります)

.
├── apps/ # デプロイ対象となる各アプリケーション層のコードを書く場所
│   ├── kyozai-app/
│   │   ├── build.gradle.kts
│   │   └── src/
│   ├── kyozai-import-app/
│   │   ├── build.gradle.kts
│   │   └── src/
│   └── ...
│
├── config/ # 各種設定ファイル
│
├── db/ # DBスキーマごとにスキーマ管理用のファイルを置く
│   ├── kyozai-db/ 
│   │   └── migrations/
│   └── ...
│
├── modules/
│   ├── domain/ # ドメイン層の各モジュール
│   │   ├── common/
│   │   │   ├── build.gradle.kts
│   │   │   └── src/
│   │   ├── curriculum/
│   │   │   ├── build.gradle.kts
│   │   │   └── src/
│   │   ├── question/
│   │   │   ├── build.gradle.kts
│   │   │   └── src/
│   │   └── ...
│   │
│   ├── repository/ # オブジェクトの永続化に関するモジュール群
│   │   ├── common/
│   │   │   ├── repository-common # repository層の共通実装
│   │   │   │   ├── build.gradle.kts
│   │   │   │   └── src/
│   │   │   ├── kyozai-db-ktorm/ # DBスキーマごとのORM用テーブル定義
│   │   │   │   ├── build.gradle.kts
│   │   │   │   └── src/
│   │   │   └── ...
│   │   │  #  以下、ドメイン層の各モジュールに対応するレポジトリ層のモジュール
│   │   ├── curriculum-repository/
│   │   │   ├── build.gradle.kts
│   │   │   └── src/
│   │   ├── question-repository/
│   │   │   ├── build.gradle.kts
│   │   │   └── src/
│   │   └── ...
│   │
│   └── test-helper # テストヘルパー層の各モジュール
│       ├── common-test/
│       │   ├── build.gradle.kts
│       │   └── src/
│       ├── question-test/
│       │   ├── build.gradle.kts
│       │   └── src/
│       └── ...
│
├── build.gradle.kts
└── settings.gradle.kts

主な利用ライブラリ

ライブラリは基本的にKotlinで書かれたものを優先的に採用し、Kotlinで良いものが見つからない場合にJavaのものを利用するようにしています。

機能ごとの主な利用ライブラリは以下の通りです。

例外処理: kotlin-result

github.com

エラー処理は基本的に kotlin-resultResult 型によって行い、throw による例外発生は、基本的にInternalServerErrorとしてまとめてハンドリングできる場合にのみ利用するようにしています。 Result 型を使うことで型によってエラー処理が強制されるので、基本的にエラーハンドリングを忘れることがなくなります。

Kotlin 1.5以降では標準ライブラリの Result 型も関数の戻り値として利用できるようになりましたが、kotlin-result では Throwable 以外の型もエラー時に返せる点が大きな強みです。 例外機構を介さないので、例えばエラーメッセージのリストを返したい場合には、独自例外クラスを定義することなく、List<String> でエラーを返すことが可能です。

ORM: ktorm

github.com

KotlinのORMとしては、 Exposedが有名ですが、SELECT文を書くときの語彙が独特であるため、より生SQLに近い感覚で読み書きできる ktorm を採用しました。

継承ベースで拡張可能な設計になっており、Entity APIの部分を中心にいろいろカスタマイズ3して使っています。

利用方針として、JOIN等で複雑になりがちなSELECTクエリはSQL DSLを使って生SQLに近い形で書き、シンプルなDML系のクエリは、(カスタマイズされた) Entity APIを使って書くようにしています。

テスト: kotest

github.com

テストフレームワークは、Kotlin製の kotest を採用しました。 Rubyからの移行になるので、RSpecと近い構造で書ける DescribeSpec のテストスタイルを採用しています。

テストはタグ機能を使って「DBに依存するテスト」と「DBに依存しないテスト」に二分されており、それぞれ別のgradleタスクが定義されています。 DBに依存するテストは、実行前にDBのdockerコンテナが自動で立ち上がるようになっています。

lint: Detekt

github.com

コードスタイルを統一するために、Detekt による静的解析を利用しています。

型情報を利用した分析を行うことでKotlinっぽくない書き方の多くを検出できるので、初めてKotinを書く人の強いサポートになるかと思います。 一方で、空白の使い方など単純な違反の検出や違反の自動修正については上手くできていない点が、課題として残っています。

Webフレームワーク: 未定

現在の開発進捗はドメイン層とレポジトリ層のみで、アプリケーション層の実装には着手していないため、Webフレームワークの選定はまだ行っていません。 裏返して言えば、ドメイン層とレポジトリ層はWebフレームワークに完全に依存しない形で書かれています。

おそらく、Spring のような重厚なものではなく、Ktor のような軽量フレームワークを使うことになると思います。 また、各アプリケーションのフロントとの通信方式 (REST, GraphQL, grpc など)にも応じて採用フレームワークは変わってくるでしょう。

移行計画

N予備校のシステムの中心となる 教材管理サービス はマイクロサービスと呼ぶにはかなり大きいコードベースになっており、これを全部一括で新システムに切り替えることは大きなリスクが伴います。

そのため移行の第一段階として、現行の 問題採点サービス を置き換えるKotlin製の 新・問題採点サービス を最初に作り、Rails製 教材管理サービス から Kotlin製の 新・問題採点サービス を利用する体制の実現を目指しております。 これによって問題採点まわりのデータ体系がその他サービスと統一され、データ分析体制が促進される効果が期待されます。

問題採点サービス の切り替え後はいよいよ 教材管理サービス の切り替えに取り掛かりますが、これも「動画や参考書などの教材部分のみ」・「コース・チャプター構造などのカリキュラム構成部分のみ」・「生徒の進捗情報のみ」などのいくつかのスコープに分け、新旧並行稼働期間を設けながら行っていく予定です。

他の機能追加開発とも並行しながらの切り替えになるので、全体としては数年がかりのプロジェクトになる想定です。

おわりに

言語仕様以外の知見や慣例がほとんどわからない状態から手探りで始めたKotlin開発ですが、今ではとりあえずコーディングなどの指針も固まって、ある程度開発が軌道に乗ってきた手ごたえを感じています。

しかし、例えばデプロイやCIまわりなど、まだまだ不足している知見も多いので、現在KotlinやJVMに強いメンバーの採用を鋭意強化中です。 また、それらの経験が無い方でも、静的型付言語を使ったシステムリプレイスに興味のある方の応募をぜひお待ちしております。

JVM経験者向けポジションの採用も新規にオープンしました! hrmos.co

We are hiring!

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。 カジュアル面談も行っています。 お気軽にご連絡ください!

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com


  1. 現在はパッケージ・コース・チャプター・セクション教材(動画や参考書)がこの順番で階層関係を持っていますが、企画当初は、チャプターの下以外にもパッケージの直下にセクション教材を置けるような、柔軟なコンテンツ構成も構想に含まれていたそうです。
  2. API Gateway教材管理サービス のメイン2つについては、それぞれの主担当するチーム分けが最近行われました。
  3. 例えば、data class として定義されたEntityでDMLを実行できるようにしたり、bulk insertやbulk upsertをEntity経由でできるようにしたりしています。