新ヘルプセンターシステム#4 API開発初期段階での技術選定の振り返りと今後の展望

サムネイル

イントロダクション

プラットフォーム事業本部CSプラットフォームグループ、バックエンドエンジニアの坂本です。

この度、弊チームで開発/運用している「問い合わせシステム」「ヘルプシステム」について、技術的負債脱却を目的とした大規模なリプレイスを行いました。本記事では、API開発初期段階での技術選定やデザインパターンの設計といった取り組みについて、事例と解説を交えながら、そこで得た学びを紹介していきます。
また当時の選定の振り返りを通じて「良かった点」「課題点」を明らかにしていくことで今後の開発でどのような影響を受けたかを後半にお話しします。

開発対象のAPI

今回対象となったAPIは、基本的にはフロントエンド側から呼び出すかたちで、“MySQLからデータを取得し結果を返す”というシンプルな仕様になっています。

help-apiの構成・利用イメージ

技術選定の内容

今回のリプレイスプロジェクト(以下、本プロジェクト)では期限が決まっていたことから、学習時間をしっかりと確保できそうにありませんでした。そのため実験的な技術・ツール・アーキテクチャの未経験導入は控え、DMM内外でも広く認知されているFW/ツールを利用する方針で次の検討を進めました。

プログラミング言語(PHPからGoへのリライト)

今回、Goを選択しリライトを行いました。Goはプラットフォーム事業本部のマイクロサービスプラットフォームにてデファクト技術として採用されているため、他チームの知見を参考に開発を進められました。また「静的型付け言語」のGoにリライトすることで、そのシンプルな言語仕様により、複雑になっていた移行元の旧アプリケーションの開発効率と保守性を同時に改善できるとも考えました。そのほかにもバイナリ実行が可能なため次のメリットもあります。

  • 可搬性が高い
  • 後方互換性が高い
  • クラウドネイティブなソフトウェアでの採用率が高い

リンター/フォーマッター

Goのコードフォーマッターは、"goimports"を採用しました。gofmtの機能に加えてパッケージのimportの追加と削除を自動で行ってくれるため効率が良いからです。
一方静的解析ツールは、"go vet"または"golangci-lint"を検討し最終的にリントランナーである"golangci-lint"を採用することにしました。go vetとstaticcheckなどを含む複数の静的解析ツールでチェックでき、必要に応じてさまざまなツールを有効/無効に切り替えができるため、開発規模が大きくなった際にも柔軟に対応できると考えたからです。それ以外にも、他チームもgolangci-lintを採用していることから知見の共有を受けられる点や、GitHubActionsとエディターとの相性も良いという理由もありました。

Webフレームワーク

私を含む何人かのチームメンバーはginの利用経験があったことから、Webフレームワークは"gin-gonic"を選択し開発をスムーズに進められると考えました。gin-gonicはLaravelやRailsのようなフルスタック型のフレームワークと異なり、ディレクトリ構造までをルール化せずにアーキテクチャを自由に設定できる柔軟性があります。またミドルウェアの実装・リクエストボディ・URIパラメータのバリデーションなど、API開発に必要な多くの機能が備わっています。

ORM

DBとのやり取りが発生するため、O/Rマッパーの利用を検討しました。Goの代表的なORMである"GORM"とsql-driverをwrapした軽量な"sqlx"の2つに絞り、最終的にsqlxを採用しました。
sqlxはDBから取得したデータをstruct、map or sliceにあてはめるシンプルな機能のみを有していることから、ほぼそのままSQL文を書けるだけでなく、クエリの実行内容が把握しやすいという利便性や学習コストがかからないというメリットもありました。

マイグレーションツール

"sql-migrate"と"golang-migrate"を調査し、最終的にsql-migrateを採用しました。双方とも軽量でCLIから実行可能ですが、sql-migrateにはdry-run機能があり、実行されるSQLを確認できるので安心感がありました。またマイグレーションのステータスがわかりやすかったのも採用した理由の一つです。

デザインパターン/アーキテクチャ

次のようなMVCモデルにサービスレイヤを追加したかたちで開発を進めました。(APIなのでViewに相当するレイヤーはありません)
しかし、そのまま開発してしまうとモデルが肥大化する恐れがあるため、モデルに該当する部分をservice層とdao層に、コントローラーに該当する部分をhandler層とresponse層にそれぞれ分離する構成にしました。

                 クライアント
                 ↓↑
  response層 ←→  handler層...クライアントからのリクエストを受け取り、レスポンスを返す。
                 ↓↑
                 service層...受け取ったリクエストに応じて処理を行うビジネスロジックを置く。
                 ↓↑
                 dao層...DBや外部サービスとやりとりし、データの永続化を行うための処理を置く。
                 ↓↑
                 DB/AWS/外部APIなど

アプリケーションのアーキテクチャの決定過程では主に"MVC"と"レイヤードアーキテクチャ"の2つを検討しました。
MVCは開発メンバーがMVCベースのアプリケーション開発経験があったことから学習コストが低く、開発にすぐにとりかかれる点がメリットでした。
一方レイヤードアーキテクチャは実装コードの複雑性を解消できる可能性があったものの、当時は経験のある開発メンバーがいなかったことから、学習コストがかかりプロジェクト進捗にも影響を及ぼす心配がありました。私たちの開発チームではGoの経験はあるものの、未経験でアーキテクチャに挑戦することはリスクと考えました。また複雑性については、MVCのモデルに該当する箇所をservice層とdao層に分けることで軽減できるとも考えました。

良かった点

ここまでの流れをまとめると、今回私たちのチームがGo、goimports、golangci-lint、gin-gonicといった技術スタックをそれぞれ採用したことで次の複数の利点を享受できました。

1. 学習コスト削減とオンボーディング負担の軽減

sqlxやgin-gonicなどの広く利用されているライブラリやフレームワークを採用したことで、チームメンバーの学習コストは想定通りに抑えられました。

本プロジェクトでは、短期間で開発要員の増員を行いつつ、作業の並行度を上げ、スケジュールの短縮を試みました。そのため、新入りメンバーがコストをかけず、迅速な開発作業に取り組むことが重要でした。
結果として、新入りメンバーが自らの判断で実装に取り組むまでにそれほど時間はかかりませんでした。

2. 開発初期段階から円滑な実装

実績のあるデザインパターンを採用した結果、スムーズに開発に着手できました。

3. 最低限のコード品質の担保

Goが直感的な言語仕様であることに加えて"goimports"と"golangci-lint"の導入により、チーム全体のコーディングスタイルをある程度統一でき、コードの読みやすさと保守性が担保されました。​​​​​

課題点

開発が後半に差しかかった頃に次のような問題が顕在化しました。

1. SQL文記述による実装負荷の増大

sqlxを利用することでより手軽に直接SQLを記述できたものの、同時に複雑性が増してしまいました。
たとえば、複数テーブルから情報抽出の場合にJOIN文を頻繁に使用することになりました。さらに多種多様な条件でデータを深堀るような検索機能を実装する際には長大で複雑なSQLの作成が必要となり、sqlxでの実装は苦労の連続でした。

// FindHoge 大量のINNER JOINとIF文を使ったテストしにくいメソッド
func (a *Hoge) FindHoge(p params) ([]*Hoge, error) {
    query := `
    SELECT
        a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, ...
    FROM
    a AS a
    INNER JOIN  -- 省略
    INNER JOIN  -- 省略
    INNER JOIN  -- 省略
    INNER JOIN  -- 省略
    INNER JOIN  -- 省略
    INNER JOIN  -- 省略
    INNER JOIN  -- 省略
    INNER JOIN  -- 省略
    INNER JOIN  -- 省略
    WHERE
    a.hoge = ?
    AND -- 省略
    AND -- 省略
    AND -- 省略
    AND IF(?, -- 省略
    AND IF(?, -- 省略
    ORDER BY
        -- 省略
    `
    // ...省略
    return hoge, nil
}

この問題はsqlxをラップしたORMを自作することでしのぎましたが、自作ORMのテストコードの実装ができておらず、保守の継続も難しいと判断し、最終的に"GORM"へ置き換えました。

2. service層の肥大化による開発効率の低下

サービスが大きくなるにつれてビジネスロジックの大部分がservice層に集約され、複雑なユースケースを有する一部のAPIでservice層のメソッドが肥大化しました。その結果、開発効率と可読性が低下し、さらにテストコードの実装負荷が上がりました。またリリース後に判明した不具合は、次のような複雑な処理が多発していました。

// UpdateHogehoge 長くて可読性の低いメソッド
func (a *hogehoge) UpdateHogehoge(p *Params) (*Mogumogu, error) {

    // パターンA
    if p.Hoge != nil {

        // AのなかのパターンB
        if B {
            // ...省略

            // A-BのなかのパターンC
            if C {
                // ...省略
            }

            // A-BのなかのパターンD
            if D {
                // ...省略
            }

            // 処理が続く

            // A-BのなかのパターンE
            if E {
                // その他、延々と続く悲観的排他処理
                // ...省略
            }
        }
    }

    return mogumogu, nil
}

これらの悪影響を少しでも改善するために処理をプライベートメソッドに分割し、プライベートメソッドごとにモックを生成してテストコードの実装負荷を軽減するなどの対策を試みました。
しかし根本的な解決にはデザインパターン、アーキテクチャそのものの見直しが必要という結論に至り、現在レイヤードアーキテクチャへの再構築を進めています。

また、一つのAPIに複数の責務を持つ設計にしたこともservice層肥大化の原因でした。しかし責務ごとにエンドポイントを分割することで、MVCモデルであってもservice層の肥大化を防げた可能性があったものの、APIを分割すると一度に叩かれるエンドポイントが増えるためにパフォーマンスの悪化を招く恐れもあります。結局のところ、API設計についてはどのように対処すべきだったのかについては、私のなかでまだ明確な答えは出ておらず、改善は今後の課題でもあります。

学びと今後の展望

ORMの選定について

ORMの選定時、移行元のアプリケーションでどのようなSQLが発行されているか確認し、そのクエリの内容を元に検討すべきでした。一から新規開発する場合には発行されるSQLを推測しないといけませんが、今回のようにリプレイスの場合はすでに稼働中のアプリケーションが存在しているので容易に調査できます。また学習コストを抑えるためにsqlxを今回採用したものの、結果的に「急がば回れ」でした。今後は具体的なデータやコードベースで調査した上で決定していきたいと思います。

デザインパターンとservice層の肥大化について

今回MVCパターンを採用したことで開発初期段階の実装をスムーズに​進められたことは良かった点でしたが、スピード感を求めるあまりに保守的になってしまったことは否めません。とはいえ、仮にレイヤードアーキテクチャを採用していたとしてもうまく実装できたとは断定できませんし、当時の選定は最良だったと考えています。
今回の経験からデザインパターンがコードの保守性と開発効率に大きく影響することを体験し、その重要性をよく​認識できました。この感覚を忘れずに、今後は意識的に問題が表面化する前(API設計の段階やプルリクのレビュー時など)に対策を打っていきたいと考えます。
現在レイヤードアーキテクチャにピボット中で、以前よりも開発/保守がしやすくなるよう前向きに改善に努めています!

まとめ

本プロジェクトにおけるAPI開発初期段階の技術選定とデザインパターンの設計、その過程で得られた「良かった点」「課題点」を振り返りました。実装速度と課題解決を目的に、Goやsqlx等を採用し、学習コストを抑えながら開発効率と保守性を改善できるツール選定に関しては、当初のもくろみ通りに開発がスムーズに進められました。一方で複雑な検索機能や多機能なAPI実装において複雑性と実装負荷が増大してしまったことで、慎重な技術選定とデザインパターン設計の重要性を再認識できました。今回ご紹介した内容以外にも非常に多くの学びが得られ、これらの経験を活かしながら今後のプロダクト開発に取り組んでいきたいと考えます。
また本記事が技術選定やデザインパターン設計の一助となれば幸いです。