MusubuアプリケーションのRailsからGoへの移行

自己紹介

はじめまして!Baseconnectのエンジニアの朱です。 私は2019年に入社してからバックエンドエンジニアをしており、DBA、テックリードのロールを努めています。

今回、弊社のプロダクトである「Musubu」で、新たなプロジェクトを開発するにあたり、古いDBのライブラリの制約や、それに伴う様々な難しい条件と対峙することになりました。

この記事では、それらの条件をクリアしていくために議論したことや、実際にRailsからGoへ部分的に置き換えた事などを紹介してみようと思います。

現状

Baseconnectに入社したときには、すでにNeo4jをメインDBに、Elasticsearchを検索用のDBとして採用されており、アプリケーションのバックエンドはRailsによって書かれていました。 簡単に構成をまとめると、以下のような感じです。

  • アプリケーションのバックエンドの言語
  • DB
  • メインDB: Neo4j
  • 検索用: Elasticsearch

Baseconnectには一般外部公開の企業情報提供サイトと、企業データと営業活動を結びつけて支援する主要プロダクトであるMusubuがあり、両方とも上記のアーキテクチャになっていました。

今回の記事で紹介する移行は、主力事業のMusubuで行われたものです。

新しいプロジェクトに向けて

Musubuには大きく分けて、

  • 企業データの検索機能
  • 企業データと連携したユーザーの営業データ管理機能

の2つがあります。 このうち、”「営業データ管理機能」を刷新して強化をする" という事業戦略が出てきました。

実際にビジネスと打ち合わせながらその設計を進めていくうちに、データモデルと機能の大幅な刷新が必要であるという事が浮き彫りになってきました。 しかし、既存のシステムのアーキテクチャでは、それらを実現するためにはいくつかの障壁があったのです。現状の整理をし、出てきた問題点を紹介したいと思います。

Neo4jの問題

『Neo4j』とはグラフDBの一種です。パナマ文書の解析で脚光を浴び、その名前が広く知られるようになりました。 Neo4jは複雑なグラフ構造を、RDBよりも簡単に表現でき、その検索を高速に行うことができることを特徴としています。

しかしながら、現状のアプリケーションでは、採用された当初で想定していたほどにはこの特性が必要とされておらず、Neo4jを採用するメリットが極めて薄い状態になっていました。

また、Neo4jを利用するクライントのほうも問題がありました。

Neo4jのORMが activegraphであり、内部で依存しているドライバー(neo4j-ruby-driver)が、Neo4j公式サポートのものではないという事です。Neo4jはそもそもRuby言語自体を公式ではサポートしておらず、Rubyのサポートはコミュニティ開発によって支えられているのが現状です。 また、ドライバー自体のスレッドセーフティに問題があり、アプリケーションをプロセスごとで起動せざるを得ませんでした。 そして追い打ちをかけるように、ORMの最新のメジャーバージョン11では、CRuby自体のサポートが切られて、Neo4j自体のバージョンの更新もままならない状態に至っていました。

Elasticsearchとの同期問題

システムでは、Neo4jでは足りなかった検索性能を補完するために、Elasticsearchが使われています。 例えば、Musubuにおける企業検索機能の複雑な検索や、検索条件に応じたリアルタイムの集計機能といったNeo4jでは実現が難しいところを、Elasticsearchが担っています。

あくまでDBのメインはNeo4jなので、データの変更があった場合はそれをElasticseachに逐次反映させる必要があります。この「複数DBの更新」という行為が、トランザクションの制御や、更新トリガーの漏れといった問題を容易に引き起こし、結果かなりのデータのずれを引き起こしていました。 そして、その都度データの確認と修正を行っていたために、エンジニアのリソースがかなり割かれていました。


こうして多くの問題が浮き彫りになっていく中で、やはり一番大きな問題はデータの一貫性の保証でした。 ORMサポートの停止によっていよいよNeo4jの延命措置もできない。刷新されるモデルにNeo4jの実装が耐えられないのであれば、メインDBを変えなければならないのでは、という議論が持ち上がりました。

Goを使うという提案

DBを変更するとなると、アプリケーション自体もほぼ書き直しをせざるを得なくなります。 そこで、どうせやるなら、「Railsのままではなく、Goでやったらどうか」という提案を私がしました。 Goを提案した理由は、

  • 静的型付け言語の世界を経験してみたい
  • Rails 以外の開発を経験したい

とわりとラフでした。 バックエンドのメンバーからの反応は悪くありませんでした。しかし、全員ほぼ未経験の言語をいきなり採用するというリスクはかなり大きいものになります。 開発に携わるメンバー間で、Railsのままでやるという選択肢も含めて、他の言語の選択肢なども探り、何回か議論を重ねました。 Goを検討するにあたって出てきたものは簡単にまとめると以下のような感じです。

▽メリット

  • 型が厳密で、コード品質が上がる
  • 学習コストが比較的に低い
  • コンテナ運用がしやすい

▽デメリット

  • ほぼ全員が未経験で、どうなるのか予想できない
  • Rails のようなフレームワークがなく、ディレトリー構造ですらも考える必要がある

Goは静的型付けによるアプリケーション開発の品質向上が期待でき、また同じような他の言語より移行の学習コストが低く、並行性のパフォーマンスの高さなどが注目されていました。 一方で、採用の難しさや、失敗事例の存在など、反対意見ももちろん出てきました。 しかし、Goにチャレンジしてみたいという気持ちと、それを支持してくれたマネージャーたちがいたことで、経営レベルの採用の判断に繋がりました。

Goによるリプレイス

Goへのリプレイスを、既存のアプリケーション全てで行うというのは現実的ではありません。あまりにもシステムが大きすぎるからです。 そこで、機能領域で分割し、営業リストの機能をGoでリプレイスして、それ以外の機能はRailsに残すことになりました。

まず、ベースの実装として弊社のアーキテクトの大槻が一人で進めて行くことになりました。 そしてそれをベースとして、Goの経験が初めてのエンジニアメンバーで実装していく進め方を取ることにしました。

アーキテクチャの刷新

現状のシステムでは、Railsアーキテクチャに限界を感じている部分がありました。 RailsMVCで作られていますが、モデルのオブジェクトとデータが密結合で、複数のモデル(M)をまたがった処理(ビジネスロジック)を書く場所が用意されていません。

この部分に対する解釈は人によってかなり異なっていて、社内のルールもありませんでした。 その結果として、『アカウント』などの汎用的なモデルが超巨大になったり、特定のモデルのルールが他のモデルのメソッドに書かれていたり、コントローラー(API)にすべて詰め込まれたりと、全体がカオスになり、把握が困難で、実装時の事情を記憶している人に頼らざるを得なくなっていました。

無論、これらの問題は単純にアーキテクチャだけに起因するものではないのですが、アーキテクチャを変更することで、かなり改善する見込みがありました。そこで、軽量DDDとクリーンアーキテクチャを採用することになりました。

DDDとは、ドメイン駆動設計(Domain-Driven Design)の事であり、複雑なソフトウェアを設計・開発するためのアプローチです。ビジネスの専門知識やルールを、「ドメイン」と呼ばれるモデルに落とし込み、それをソフトウェア設計の中心に置きます。

そしてクリーンアーキテクチャは、この中心となるドメインを、フレームワークやDBなどに依存しないように関心の分離を行うことで、機能の改修や開発、バグ修正などを行いやすくするための設計方法です。

ここでは詳しく説明しませんが、これらの技術スタックに関しては世の中にかなりの数資料があるので、興味がある方は調べてみてください。

私は、これらのDDDやクリーンアーキテクチャについては概念としておぼろげな理解はあり、そういう形でアプリケーション開発をしてみたいと思ってはいたものの、実際には実装した経験はありませんでした。

開発がスタートし、やっている最中は、常に検索して資料を見つつ、相談しつつ、実装していきました。 中でも、一番悩ましかったのは、パフォーマンスとコードによる仕様の表現の綺麗さという点でした。

Musubuでは営業のためのリストを扱いますが、リストの中身の件数が多くて、その処理のパフォーマンスがユーザーの体験に直結するため、常にそのパフォーマンスを考慮する必要があります。

しかし、パフォーマンスのための最適化をやりすぎると、コードによって表現すべき仕様がわかりづらくなり、潜在的なバグになりかねません。パフォーマンスをあまり損なわないところで、コードのクリーンさをどこまでどう保つかということが、議論や相談のポイントになっていました。

リプレイス後のコードでは、クリーンアーキテクチャによるレイヤー分けと、その責務が意識化されました。

それによって、どこに何を書くべきかの範疇が以前よりもかなりはっきりしていて、同じロジックのコードの分散の問題がかなり解消されました。また、単体テストもレイヤー間の疎結合によってしやすくなっており、ほぼAPIのテストしか書かれていなかった時から、安心してコードの再利用ができるという状態になりました。

Go リプレイス後

具体的な恩恵を紹介してみたいと思います。

厳密な型による恩恵

Ruby (on Rails) のコードでは、特定のメソッドの使用を、文字列の検索を使って人力でやる必要があり、全部網羅できているかの不安が常につきまとっていました。

特に、Rubyメタプログラミングを利用したコードが、メソッドの利用状況を隠蔽し、インテリセンスを混乱させるため、更に不安を増大させます。また、メソッドの引数の型が明らかでない場合に、そのメソッドの呼び出しのコードから割り出す必要があったり、そもそも、モジュールの参照先が非常にわかりにくいために、メソッドの定義そのものを探したり、定義のコードを読み解くのに、周辺のコードの習熟する必要がありました。

一例として、次のようなコードで、

class Model
    attr_accessor name
    
    include ModelModule
    
    def hogehoge
        hoge # この hoge はどこからきた?
    end
end

## 別のファイルに

module ModelModule
    def hoge
        puts name # この name はいったい何??
    end
end

hoge メソッドを読むときに、name という変数ないしはメソッド呼び出し(それすら文脈がないと、判断できない!)に出会ったら、これはいったいどこに定義されているものかは、元のモデルのクラスについて熟知していないと、読み解くことが難しいといえます。

逆に、hogehoge メソッドで hoge を呼び出しを行っていますが、モジュールの中身を知らないと、hoge が唐突に感じます。こういったコードは、Rails のコードではよく見かけるのではないでしょうか。このように短いコードで、クラスとモジュールの定義が近くにあると実感しにくいですが、モジュールが何個もあって、モジュールのコードが全然別の場所にあると、これを読み解くのがかなり労力がかかる事になるでしょう。

同じようなコードを Go に書き換えると、以下のようになります。

type Model struct {
    Name string
}

func (m *Model) hoge() {
    fmt.Println(m.Name)
}

func (m *Model) hogehoge() {
    m.hoge()
}

どうでしょう。メソッドなのか、フィールドなのかは一目瞭然になったのではないでしょうか。フィールドがすべてstructで定義されるので、(同列では語れないが)突然インスタンス変数が追加されることもありません。(*注:Go には Ruby の mix-in にそこそこ近い機能として、構造体の埋め込みがあるが、埋め込まれた構造帯のメソッド内では、埋め込み先の構造体のフィールドにアクセスできない。)

すべてのメソッド、構造体のフィールドの追跡が、Go の 公式 Language Server (gopls) によって確実にできるようになっているので、部分的にでも読み解くことも容易です。また、typoの検出、リファクタの実施が容易にできるようになりました。

これによって、開発体験とコードの堅牢性がかなり上がったと感じます!

アーキテクチャの変更による恩恵

リプレイス後のシステムでは、クリーンアーキテクチャによって、アプリケーションにレイヤーができ、各レイヤーの役割が明確、かつ、疎結合になっています。

かつてのように、DBからAPIまでのすべてを結合しての複雑なテストが不必要になり、シンプルな単体テストが大幅に拡充され、テストの実行時間も短縮されました。

DBから切り離されたドメイン層ができて、ビジネスロジックドメイン層で実装するというルールができ、複数のエンティティ(Rails でいうモデルの近い)に跨る処理は、集約ないしドメインサービスで書かれるようになりました。

ドメイン層で実装されたこれらのコンポーネントが、複雑なユースケース層を実装する時に再利用できるようになりました。これによって、実装者が毎回すべてのデータの運用ルールをすべて把握した上で実装する必要がなくなり、実装者もコードレビュアーも認知負荷が下がり、把握漏れによるバグが大幅に減りました。

アウトロ

ここではDB上の制約に立ち向かうために、RailsからGoへの置き換えを実際に進めた経験を紹介してみました。 また、記事中でも触れましたがメインDBの変更も今回は行われており、そちらは弊社の文が記事を書いてくれています。

techblog.baseconnect.in

私自身、振り返ってみるとクリーンアーキテクチャをちゃんとした形で実現できていない部分もあるな、という感想もあります。設計・実装時点では、Goでクリーンアーキテクチャをサポートするようなライブラリ等のデファクトスタンダードがこれといって見つからず、都度手探りで進めていきました。

C#Java等の他の言語でクリーンアーキテクチャを実現している例などを参考に、当時はGoに落とし込めていたと思っていても、実装が終わり、理解が深まった今、見返してみるとやはり不完全な部分があるなと感じてしまいます。

一方で、既存のシステムの中枢アーキテクチャであるRailsという枠組みから離れ、Goという新たな試みによる挑戦。そして、その中でほぼ自力でアプリケーションを構築し、設計から細部までを自分たちで考えて作り上げた事は、アーキテクチャやGo自身の理解と習熟に繋がり、自信が生まれました。もちろん苦労した分、愛着もあります(笑)

リプレイスは簡単な事ではなく、時には困難な事もありますが、しかしそれを補って余りあるメリットもあると言えます。 私の記事が、皆さんのGoへのリプレイスや、Goで新たなプロジェクトを立ち上げるきっかけや参考になれば幸いです!