メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

特定保健指導"フィッツプラス"事業を支えるモノリシック Rails + VIPER Swift アーキテクチャ

みなさんこんにちは。フィッツプラス開発エンジニアの福本(@terry_i_)です。
早いもので入社して半年が経ちました。普段はRailsを中心に色々と書いてます。

リモートワークが長く続いていることもあって、最近は自宅の開発環境を(過剰に)整備するのがマイブームです。先日はlogicoolのPCスピーカーを買いました。所得がゴリゴリ削られていってツラい。

さて今回は、これまで忙しくて紹介する機会のなかったフィッツプラスの事業概要や、アーキテクチャおよび使用する技術についてお話しします。

アーキテクチャに悩むエンジニアの方の参考になったり、皆さんのフィッツプラスへの事業理解が深まれば幸いです。


特定保健指導とは?

いきなり技術の話に入る前に、タイトルの”特定保健指導”という事業ドメインについて簡単にご説明します。

www.mhlw.go.jp

この”特定保健指導”という単語で、すぐピンと来るエンジニアの方は多くないでしょう。というのも、特定保健指導は健康保険に加入している40歳以上の方を対象に実施されているためです。

私も例に漏れずピチピチの若エンジニアですので、あまりよく知りませんでした。今は若い方もその内お世話になることと思います。

特定保健指導は、特定健康診査という定期検診で対象となった(要するに”引っかかった”)方の生活習慣病の予防および改善を目的に行われています。

具体的な内容としては、有資格者が対象者と最初に面接をし、その後一定期間継続的にサポートするプログラムです。それを”管理栄養士”という国家資格を有する専門職の方が、食生活を中心としたアドバイスを行って、生活習慣改善のサポートを行っています。

フィッツプラスはその”特定保健指導”を行うtoB向けのWebサービスを中心に、一般の方向けにも食事のアドバイスを行うアプリを開発・運営しています(後述)。つまり、フィッツプラスはメドピア内で”食”の観点から予防医療をケアする立ち位置で事業を推進していることになります。

メドピアグループはヘルステック企業として、幅広い医療領域を技術でサポートしています。中でも「予防領域」は、高齢化社会により高騰した医療費の削減などの社会的背景から、昨今とても重要視されております。

アーキテクチャ

f:id:terryyy:20200620202230p:plain

さて、この章から具体的な技術の話をしていきます。上記の図は、先ほど説明したフィッツプラス事業のサービスの中核であるRailsアプリケーション(dietplus-serverと呼んでいます)と、関連するアプリやサービスとの関係を図にしたためたものです。

「関連する」という表現ですが、この図には記載されていないWebサービスが複数稼働しています。モダンなプロジェクトで言うと、Nuxt.js + Rails6 でのSPA構成のサービスを絶賛開発してたりします。完成した暁には、そちらの担当エンジニアが記事を書いてくれると思うのでマァ首を長くして待っていてください。

上記の図をすべて解説すると薄い本が1冊書けてしまうので、中心となるRailsサービス『dietplus-server』と、上部オレンジ色の領域にあるiOSアプリケーション『DietPlus』のふたつに的を絞って今回はお話します。

以降では、まず裏側を支えているdietplus-server(Rails)について、その後にDietPlus(iOS)について解説します。そうすることで、現状のアーキテクチャ全体でのトータルなメリットや課題感をお伝えできればと考えています。

そういった目的上、RailsとSwiftの両方について触れています。「Swiftの話だけ聞きたいんだ俺は」という方は、お手数ですが、”VIPER Swift”の章から読んでいただけると幸いです。

モノリシック Rails

f:id:terryyy:20200620193739p:plain

中核となるRailsですが、先ほどの図では詳細が分かりづらいので、今回お話したいAPIと管理画面に関わるライブラリを記載した図を別途作ってみました。特徴的な部分について説明していきます。

ちなみに、2020年6月1日時点のrails stats では以下のような結果となりました。Rails のサイズ感が伝われば幸いです。

+----------------------+--------+--------+---------+---------+-----+-------+
| Name                 |  Lines |    LOC | Classes | Methods | M/C | LOC/M |
+----------------------+--------+--------+---------+---------+-----+-------+
| Controllers          |  11144 |   9420 |     223 |     840 |   3 |     9 |
| Helpers              |    212 |    175 |       0 |      27 |   0 |     4 |
| Jobs                 |    447 |    364 |      14 |      18 |   1 |    18 |
| Models               |  13809 |   8639 |     167 |     799 |   4 |     8 |
| Mailers              |    586 |    502 |      26 |      66 |   2 |     5 |
| Channels             |      8 |      8 |       2 |       0 |   0 |     0 |
| JavaScripts          |     67 |     21 |       0 |       4 |   0 |     3 |
| Libraries            |   1019 |    909 |       7 |       9 |   1 |    99 |
| Mailer specs         |      8 |      6 |       1 |       0 |   0 |     0 |
| Decorator specs      |     67 |     60 |       0 |       0 |   0 |     0 |
| Loyalty specs        |    205 |    147 |       0 |       0 |   0 |     0 |
| Model specs          |   6153 |   5438 |       0 |       0 |   0 |     0 |
| Request specs        |  10968 |   9586 |       0 |       0 |   0 |     0 |
| System specs         |   6937 |   5931 |       0 |       0 |   0 |     0 |
| Lib specs            |    659 |    560 |       0 |       0 |   0 |     0 |
| Job specs            |    154 |    123 |       0 |       0 |   0 |     0 |
+----------------------+--------+--------+---------+---------+-----+-------+
| Total                |  52443 |  41889 |     440 |    1763 |   4 |    21 |
+----------------------+--------+--------+---------+---------+-----+-------+
  Code LOC: 20038     Test LOC: 21851     Code to Test Ratio: 1:1.1

ActiveModelSerializers

APIでJSONを返すオブジェクトの作成はActiveModelSerializersで行っています。いちいちviewファイルを作ってレンダリングさせる必要がなく、関連オブジェクトの指定もRailsっぽく書けます。メドピアでは過去に他のチームでも採用情報があり、かつ一般的にもよく使われるgemなので特に違和感なく使えています。

tech.medpeer.co.jp

OpenAPI

各APIの定義はOpenAPI仕様のドキュメントをSwagger Editorで書き、Swagger UIで閲覧しています。

特徴としては、当初のアーキテクチャ図の通りAPIのレスポンスを返す先のアプリケーションが2つ(図のオレンジと緑の領域)ある点です。幸い2つのAPIは互いに独立しているので、各レスポンス先ごとに spec.yml の参照パスを分けた docker-compose のコマンドを、以下のようにMakefileを作って運用しています(一部改変しています)。

# Makefile
## Swagger-ui
### DietPlus
dietplus/api/docs:
    docker run --rm -p 8591:8080 -v $(CURDIR)/${DIETPLUS_API_SPEC_PATH}:/usr/share/nginx/html/spec.yml -e API_URL=spec.yml swaggerapi/swagger-ui
### DietPlus Pro
dietpluspro/api/docs:
    docker run --rm -p 8591:8080 -v $(CURDIR)/${DIETPLUS_PRO_API_SPEC_PATH}:/usr/share/nginx/html/spec.yml -e API_URL=spec.yml swaggerapi/swagger-ui

OpenAPIについては、私が後からSwaggerを導入したため、モック環境など一部整っていない部分があります。引き続き徐々に環境整備を進めていきたいという気持ちです。気持ちはあります。

Houston(プッシュ通知)

RailsからiOSアプリに対してのプッシュ通知(いわゆるAPNs)は、HoustonというgemをActiveJobと併用して行っています。Houston::Notificationをインスタンス化するだけで、iOSアプリに送る通知のバッヂや音声を簡単に設定し送信できます。

問題としては、執筆時点でmasterがiOSのバージョン13のプッシュ通知に対応していない点が挙げられます。

詳細は以下のIssueに記載されていますが、Apple Developersが要求するheaderの情報をgemで設定できないことが原因です。

github.com

幸いにもこのIssueに対応するPRが上げられているので、現在はGemfileのgitオプションを使用し該当するcommitを取り込む形で対処しています。実際には以下のように記述しています。

# ios push notification
# TODO: マージされたら git オプション外す
gem 'houston', git: 'https://github.com/ab320012/houston', ref: 'efbeb6c'

上記の対応には若干懸念が残っていて、オプションでcommit hashを直接指定している関係上、ハッシュ値が変わってしまった場合にbundle installできなくなります。rebaseforce pushなどが行われると、参照しているcommit hashの値が変わってしまう危険性があるようです。*1

Banken(権限管理)

管理画面にログインするユーザー権限の管理手法として、Bankenを採用しています。

github.com

前提として、後述の『DietPlus』を含む複数のスマホアプリを同じ管理画面を用いて管理しています。そして、アプリごとにメニューから画面を切り替えて操作するようになっており、(当然ですが)他のアプリの管理栄養士や管理者がユーザーの個人情報を見られないようにしています。また、同じアプリ内の画面でもセンシティブな情報(例: ユーザーとのチャットのやり取り)が含まれるものがあったりするため、画面ごとの細かい権限の制御が必要です(詳細は後述)。

アプリごとの namespace (実際はmodule)が複数存在し、画面ごとに権限を定義する必要があるため、Controllerベースで権限を付与するBankenは違和感なく使えています。RSpecでテストコードを書く際は、Request Spec内で権限ごとにループでテストを回しています(以下例)。

shared_examples_for "アプリの管理者と開発者のみアクセスできる" do
  [
    { name: 'アプリ管理者', trait: :app_admin },
    { name: '開発者', trait: :developer },
  ].each do |user_value|
    context user_value[:name] do
      let!(:user) { create(:admin_user, user_value[:trait]) }

      it { expect(response.status).to eq 200 }
    end
  end
end

context '各権限でアクセスする' do
  before { get admin_app_index_path }

  it_behaves_like "アプリの管理者と開発者のみアクセスできる"
end

VIPER Swift

f:id:terryyy:20200620194007p:plain

ここからは、クライアントサイドであるSwiftコードの設計と使用するライブラリについてお話できればと思います。

今回スポットを当てるアプリ『DietPlus』ですが、食事の写真を投稿すると管理栄養士の方がアドバイスをしてくれるサービスです。2019年10月にiOSアプリをフルリニューアルしてリリースし、その後いくつかの機能追加や改善を行いました。

medpeer.co.jp

こちらもコードのサイズ感をお伝えすると、2020年5月時点でのcloc の実行結果は以下のとおりです。

$ cloc --include-lang=Swift,Objective\ C --exclude-dir=Pods,Carthage ./
    6401 text files.
    6269 unique files.
    5721 files ignored.

github.com/AlDanial/cloc v 1.86  T=4.17 s (168.1 files/s, 13199.7 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Swift                          697           9707          11423          33492
Objective C                      5             86             37            363
-------------------------------------------------------------------------------
SUM:                           702           9793          11460          33855
-------------------------------------------------------------------------------

DietPlusのSwiftコードにおける特徴は、ピュアなVIPERアーキテクチャで構築されている点でしょう。VIPERについて詳細に書くと薄くない本が数冊書けてしまうので割愛しますが、いわゆるClean Architectureの一種です。

qiita.com

Rubyエンジニア的に言うと、フレームワークの『Hanami』に近いイメージがあります。Actionごとにクラスを切って1画面≒1クラスになる点や、ViewsとTemplateファイルが独立している点などが、VIPERのViewやPresenterの仕組みと似通っていると感じました。*2

Entityがキモ

f:id:terryyy:20200616231704p:plain

さて、VIPERにおいて最も設計が難しい点のひとつ(諸説あり)は、Entityに何を置くかでしょう。Clean ArchitectureではEntityを「アプリケーションに依存しないドメインおよびビジネスロジック(を示すデータの構造やメソッドの集合)」だとしています。*3

このEntityをインターフェースやDBから完全に切り離し、依存の方向を一方向にすることで(図参照)UIなどの変更が多い部分を変更しやすく、そうでない部分に影響を与えないようにします。この設計をいかに維持できるかで、プログラムの変更を容易にできるかが決まります。

DietPlusにおけるEntity

DietPlusにおけるEntityですが、結論から言うと「ユーザーの食事」と「食事の日付」に関わる部分が、最も中心的なドメインロジックとなっています。

人間の食事習慣や日付といった概念は普遍的なものですが、その食事や日付に対して「どうコメントを返信するか」「どういう」は、サービスを提供する私たち側の問題です。これをしっかり分けて考えることで、変更の多い部分をできる限りInteractorPresenterに切り出せています。

具体的にはこんな感じのコードが、ユーザーの食事投稿を表示するPresenterに書かれていて、Entityである食事(Meals)をUIで表現するデータに変換しています。

// MARK: - MealRecordPresenterProtocol
final class MealRecordPresenter: MealRecordPresenterProtocol {

    struct Constant {
        // 反映可能食事枚数の上限
        static let maxMealPhotoCount: Int = 4
    }

    
    struct InitialState {
        var date = Date(second: nil)
        var memo = ""
        var selectedCategory: MealCategory = .breakfast
        var selectedStyle: MealStyle = .home
        var tags: [MealTag] = []
    }
    
    // 取得可能枚数の上限
    private var maxAddableCount: Int {
        return Constant.maxMealPhotoCount - photos.count + deletePhotos.count - addedImages.count
    }
    private(set) var date = Date(second: nil)
    private(set) var photos = [Photo]()
    private(set) var deletePhotos = [Photo]()
    private(set) var addedImages = [UIImage]()
    var memo: String = ""
    var mealTags = [MealTag]()
    var selecetedCategory: MealCategory = .breakfast
    var selectedStyle: MealStyle = .home
    
    private(set) var mealDetail: MealDetail? // Editの場合に取得
    private(set) var initialState: InitialState?
    private var completionHandler: (() -> Void)?
    
    weak var view: MealRecordViewProtocol!
    var interactor: MealRecordInteractorProtocol!
    var router: MealRecordRouterProtocol!
    
    init(completionHandler: (() -> Void)?) {
        self.completionHandler = completionHandler
    }
}

一方、食事を表すEntityであるMeal.swiftはシンプルに書かれています。

ここにすべては記載できませんが、ファイル内のSwiftコードはExtensionの拡張を含めて66行でした。主要なEntityとしては薄い部類だと感じます。

struct Meal: Codable {
    
    let id: ID
    let time: Date
    let category: MealCategory
    let style: MealStyle
    var content: String?
    var memo: String?
    let createdAt: Date
    let updatedAt: Date
    var photos: [Photo]
    var mealTags: [MealTag]
    
    struct ID: Identifiable {
        let rawValue: Int
    }
    
    enum CodingKeys: String, CodingKey {
        case id
        case time
        case category = "categoryCode"
        case style = "styleCode"
        case content
        case memo
        case createdAt
        case updatedAt
        case photos
        case mealTags
    }
 
}

Embedded Frameworkによるマルチモジュール構成

また、VIPERのレイヤーの分割や依存関係の構造を守るために、UIコンポーネントや拡張メソッドを別のモジュールに切り出して管理しています。具体的には、以下の3つにモジュールが分かれています。

# DietPlus(App)
    - アプリ本体のコード
    - 画面に関するModule(View, Interactor, Presenter, Router)
    - Entityおよびサービスクラス(API、Database, Keychain, UserDefaultsなど)
# UIComponent
    - 各種UIパーツの格納
    - Color Asset, Image Assetも基本的にはこっちで管理
    - UITableViewCell, UICollectionViewCellといったCellクラスもUIComponentに追加
    - アプリ本体のモジュールはImportしない(依存は一方向のみ)
# Common
    - Extensionメソッド(UIは除く)
    - Standard Libraryに関するUtilityクラス
    - UIに限定されない各種定義値

アーキテクチャの設計を遵守できるのはもちろん、依存の方向性をある程度強制できるので「UIComponent →アプリ本体」という依存を作り循環参照が起きてぐちゃぐちゃになるそしてしぬ…ということが防げます。他にもnamespaceをきっちり分けることで、呼び出すモジュールやクラスを明確にできるという利点があります。

qiita.com

まだ実施していませんが、EntityやAPIは他のアプリのコードと比較して変更の頻度が速くないため、これも別モジュールに切り出して良いかもしれません。

現状のメリット/課題

さて、冒頭から偉そうに解説していますが、RailsとSwiftのどちらも私が設計したものではなく、過去に在籍したエンジニアの方が設計したものです(そのため私の解釈がある程度混ざっています)。私はその恩恵に預かっているわけですが、これまで半年間の開発で感じたアーキテクチャの「メリット」と「課題」についてお話します。

メリット

少人数のエンジニアリソースで開発できる

個人的にはこれが最も大きなメリットだと感じるのですが、コードベースの大きさと比較すると、人数の少ないチームで開発を進められます。

著書『人月の神話』の中で、フレッド・ブルックスは基本的な原則を明らかにしました。小さなチームなら、どんな方法論もうまくいくのです。―ケイト・トンプソン 『ZERO BUGS シリコンバレープログラマの教え』*4

実際に2020年5月の執筆時点で、フィッツプラスはサーバサイド4名とアプリエンジニア1名の計5名で開発を行っています。今回ご紹介したサービス以外にもRailsアプリケーションが2つにPHPのサービスやPythonスクリプトなどがあり、それらの存在も考えると少ない人数ではないでしょうか。小さなチームではコミュニケーションコストを低く抑えられ、サービスの前提やコードの変更状況などの共有がとてもラクです。

また、一般的な話をすると、そもそもベンチャー企業では物理的に大量のエンジニアを採用しづらいパターンもあるかと思います。まず最初はサービスをモノリシックに作り、市場に必要とされる機能を開発していくスタイルは、オーソドックスですがひとつの解ではあると思いました。

拡張性が高く複数のアプリケーションを展開しやすい

これはVIPERのくだりでドメインを定めたおかげですが、Entityで閉じ込めたロジックが複数のアプリケーションで共有されやすい状態だと感じます。

社内にはDietPlusに近いドメインを持ったiOSおよびAndroidのアプリ(冒頭アーキテクチャ図参照)が他にも存在していますが、アプリやバックエンドともに既存アプリと同じようにコードを書くことで再現できる部分が多く、後から入った身としては助かります。

また、テストを書く際に、テストケースを豊富に書くべき部分が明確になります。具体的には、日付に関しては境界値テストを必ず書いたり、APIリクエスト時のパスが想定しない日時だった場合の異常系のテストなどを増やしケースを充実させています。一方で、管理画面上(View)では課金などのクリティカルな処理を行っていないので、System Specは薄くて済みます。

実はサーバサイドと連携するアプリを他にも増やす予定があり(まだ喋れないやつ)、現在私が担当者としてモリモリとコードを書いているのですが、こういったことを簡単にできるのはひとつの強みです。

課題

アカウントや権限の管理が複雑になる

Bankenの章でピンと来た方がいるかもしれませんが、複数の権限が必要なアプリが複数存在しているため権限が複雑になってきています。

それぞれユーザーの権限を各Modelのenumで判断しているため、権限の説明やコンテキストをコードで表現・管理するのが難しいです。マイグレーション時にDBにコメントを残すことができますが、そこに盛り込むのに権限の説明は少し長すぎます。Model内に長文でコメントアウトを残すのが妥当なラインでしょうか。

長期的には、太ってきた権限を他のテーブルに分割していく等の改善方法があるかと考えています。権限の説明をコードで把握するのを諦めて、しっかりドキュメントを残すことも大切でしょう(視線を泳がせながら🐟)。

Swiftのファイル数が多くなる

VIPERに限らず、Clean Architectureでは”ファイル数が多くなりがち”です。責務を分割すればひとつのファイル(あるいはレイヤー)あたりのコード数が少なくなるので、その裏返しと考えれば当然です。

ひとつの画面を作るために、VIPERの頭文字(E除く)とStoryboardがひとつ(不要な場合もある)の合計5つのファイルを作成する必要があります。RailsならViewファイルを作成して、Controllerとrouteファイルに追記するくらい(諸説あり)なので、比べるとやはり多いと感じます。

これについては、コードとファイルの自動生成gemのGenerambaを用ることで工数を削減しています(アプリ開発のライブラリにRuby製のgemが使われていると、Rubyistとしては少し嬉しい気持ちになります)。工数の削減以外にも、Module構成やクラス記述などを統一できるメリットもあります。

github.com

他の自動生成ツールとしては、SwiftGen でリソースと型の作成を自動で行ったりしています。

一部のコードがmodule間でDRYにならない(しづらい)

A::UserB::Userといった別moduleの類似クラス(AやBはアプリ名)が数多く存在するのですが、共通化すべきコードとそうでないコードの見極めが難しいと感じます。各アプリでグロース速度が異なるのでなおさらです。普段コードを書いていて「あっ、このscopeってBの方には生えてなかったのか...」ということがよくあります。共通化するにも「3つ以上のmodule間で共通して使われ続けるであろう処理」かどうかの判断は容易ではありません。

個人的には、ヘンに共通化して罠にハマるくらいなら、メンテナンスするコード量が多少増えても、影響範囲をmodule内に閉じ込めておく方が無難なのではないかと考えます。

さいごに

長くなりましたが以上です。最後までお付き合いいただきありがとうございました。アプリケーションのアーキテクチャや使用するgemなど、皆さまになにか得るものがありましたら幸いです。

冒頭で事業について触れましたが、メドピアはメインサービスである「MedPeer」を中心に、さまざまな医療領域をカバーするため新しい事業をつぎつぎと立ち上げています。事業やプロダクトが社内にたくさんある現状から学べること・経験できることはとても多く、エンジニアとしてとても魅力的な環境だと思います。

ステマみたいになりました しかし、プロダクトをより良くするために、私たちにはエンジニアの力がもっと必要です。というか一生「足りない」って言い続けてる気がしますが、そんな中でも一緒に走りながらお互いを高めあえるエンジニアの方はぜひメドピアへ!

■募集ポジション

medpeer.co.jp

■開発環境

medpeer.co.jp

*1:リポジトリをforkしprotect branchすればケア可能: 参考

*2:実際、HanamiはClean Architectureに影響を受けているそうです: 『Hanamiフレームワークに寄せる私の想い(翻訳)』https://techracho.bpsinc.jp/hachi8833/2018_03_28/54381

*3:『クリーンアーキテクチャ(The Clean Architecture翻訳)』https://blog.tai2.net/the_clean_architecture.html

*4:"37.象の多くの側面"より引用