受注管理機能を支える技術 〜 VueCompositionAPIとGraphQLとAtomicDesignとScopedStyle〜

こんにちは、 @mugi_uno です。

少し前に背骨の手術を受けたら身長が伸びました。

🎉 受注管理機能をリリースしました

2020/12/10に、Misocaに新しく「受注管理機能」をリリースしました。

www.misoca.jp

f:id:mugi1:20201217165947p:plain

いままでは、請求書・見積書・納品書といった単位でのステータス管理が主でしたが、新たに追加された受注管理機能を使うことで、案件単位でステータスを管理しつつ、各文書への変換も簡単に行えるようになりました。

そして、同時にこの受注管理機能は、開発面においても様々な新しい技術面でのトライもありました。

  • Vue.js & Vue Composition API
  • GraphQL
  • Apollo Client & Vue Apollo
  • Atomic Design
  • Scoped Style

今回は、これらについてどういった対応をしたのかと、リリース後にふりかえってみてそれぞれよかった点、大変だった点についてのお話です。

前提 / 以前から利用している技術要素と受注管理機能の基本構成

受注管理に限らないMisoca全体の代表的な技術要素として、次のものを以前から利用しています。

  • バックエンド: Ruby on Rails
  • フロントエンド: TypeScript & Vue 2.x
  • API: REST
  • デザイン: SCSS (*.scss ファイルに記述)

新たに作成した受注管理機能はSPAで構成されており、次のような機能を実現しています。

  • ステータスの追加・変更・削除
  • 受注情報の新規作成・複製
  • 受注情報のステータス変更
  • 履歴の確認
  • コメントの追加
  • 検索

Vue.js (Vue Composition API)

受注管理機能の開発着手時点で、すでに Vue 3.x 系のリリースも見えている状態で、当時の選択肢としては2つ考えられました。

  • 従来の Vue 2.x 系の記法のまま実装する
  • Vue Composition API を利用する

Vue 3.x 以降も従来の記法はサポートされるので、無理に新しい記法とする必要はなかったのですが、「どうせ近い将来にすべて書き換えるなら、最初から Vue3フレンドリーな形で実装していこう」と、Vue Composition API を利用した形を主軸に実装しました。

github.com

結果としては、2020年9月時点で Vue3 が正式リリースされました。MisocaではIE対応の必要性から未導入ですが、後々必要となるマイグレーションを考えると、良い決断だったかなと思います。

👍 良かったこと

共通処理を Vue Composion API を使って簡単に切り出せるのは使い勝手が良かったです。

例として「新規作成と編集と複製って、ロジックがほぼ同じなんだけどちょっとだけ違うんだよな〜」といった際に、共通ロジックを useEditForm.ts として切り出す、といったことが簡単にできます。

また、Composition API は TypeScript との親和性が高く、機能追加・変更時も安全に手を加えていける点も良かったです。

🙈 大変だったこと

開発までの慣れ

いままでは Vue 2.x + Class Component で開発していたため、書き方が大きく変わる Composition API に開発メンバーが慣れるまでに少し時間がかかりました。ただ最初でこそ大変でしたが、慣れてくると「こっちのほうが書きやすい」という意見が多くなりました。

なお、開発と並行して @kawamataryoの主催で Vue Composition API のドキュメントを対象にした輪読会も開催されていました。そちらも、レビュアー・レビュイー双方の観点で良い効果があったと思います。

setup関数の肥大化

Composition API は setup 関数が肥大化しがちで、意識せずに処理を詰め込んでいくと、全体的に何をやってるか見通しが悪くなるのが課題でした。対策として、「大きくなりそうなら、処理は意味のある単位でコンポーネント内のローカル関数に切り出して、setup は小さく保つ」といったルールを共有していました。

<script lang="ts">

...

const useA = () => {
   // Aに関連する処理
}

const useB = () => {
   // Bに関連する処理
}

const useC = () => {
   // Cに関連する処理
}

export default defineComponent({
  setup() {
    return {
      ...useA(),
      ...useB(),
      ...useC()
    }
  }
})
</script>

関連ライブラリの影響

Vue Router や Vue Apollo などで一部機能が未対応だったり、Vue Composition API 側の変更の煽りを受けて挙動が壊れるというのを何度か踏みました。こればかりは新しい技術を突っ込んだ以上逃げられないものですので、ライブラリ側に修正パッチを送るなどして、わりと泥臭く対応してました。

GraphQL

SPAなのでデータの取得・更新はAPI経由ですが、すべてGraphQLで実装しています。

バックエンド(Rails)では、GraphQL Ruby でエンドポイントの実装とスキーマファイル出力を行い、フロントエンドでは、スキーマファイルを元に GraphQL Code Generator を利用して TypeScript の型定義ファイルを生成しています。

GraphQLを導入した背景としては、すでにMisocaでメイン利用されていた TypeScript との親和性が非常に高いことや、将来的にモバイルなど他の領域からの利用を考えた場合に柔軟に対応できる点が挙げられます。(※ただ正直なところ、エンジニアチームとして新しい技術にトライしたかったというのもあったかなと思います。)

👍 良かったこと

GraphQL による開発体験はとても良く、特に GraphQL Code Generator 経由で生成した型定義をフロントで利用できる点が非常に快適でした。

Misocaでは開発中の仕様変更も柔軟に受け入れていくスタイルですので、DB定義も含めてガンガン変更が入ります。その際に、GraphQL スキーマの変更から連動して型定義も変更されるため、ビルド時の静的検査の時点で検知でき、簡単かつ安全に対応していくことができました。

「バックエンドが変わったのにフロントエンドの変更が漏れてる」といったこともほぼ無く、品質面でも大きなメリットがあったように感じます。

ふりかえりなどでも GraphQL 超便利といった声が多数聞けました。導入して良かったです。

🙈 大変だったこと

ついうっかり N+1 を踏んでしまうことが何度かありました。「履歴データが多くなってくると一覧がタイムアウトします!!」みたいな感じです。

根本的な対処としては、Dataloader を利用してバッチ的にデータを取得する方法などが例として挙げられるかと思います。

しかし、Misocaの場合は発行されるGraphQLクエリがある程度は予測可能でしたので、単純に ActiveRecord 側で includes を足して対処しました。それでも、可能な限り無駄なくincludes を足すため、GraphQL クエリに基づいて自動的に includes に渡す引数を生成するヘルパーを自作しました。以下はヘルパーの利用イメージです。

RELATION_MAP = {
  comments: {
    user: {}
  },
  histories: {},
  items: {},
  status: {}
}.freeze

# クエリ内容とマッピング定義から includes に渡すべきパラメータを得る
includes_params = IncludesHelper.create_includes(
  lookahead, # GraphQLRubyが提供するクエリ検査用インタフェース
  RELATION_MAP
)

orders.includes(includes_params)

これにより、あるクエリ内で comments だけ取得している場合は comments のみが includes に含まれ、別クエリで itemsstatus を取得する場合には itemsstatus のみが includes に含まれるようになっています。

状態管理

Vue & TypeScript の組み合わせで迷いやすいのが状態管理の方法かなと思います。

今回のケースでは GraphQL のために Apollo Client を利用しており、基本的なデータについては Apollo Client のキャッシュ機構を利用して状態管理の代替としています。

同時に Vue Apollo も組み合わせているので、たとえば

  1. ユーザーが何か入力する
  2. 1 を Vue のリアクティブシステムで検知して GraphQL クエリを自動発行
  3. Apollo Client のキャッシュが更新
  4. 同一 GraphQL クエリに依存する箇所すべてが自動的に再描画

といった挙動もわりと簡単に実現できます。

ただ、画面上のすべての情報が Apollo Client で集約できるかというとそんなこともなく、UI操作上一時的にどこかに保持したい かつ 様々なコンポーネントから参照が必要になる値も存在します。モーダルの表示状態やドラッグ&ドロップの一時的なデータ保持などが代表例です。

Apollo Client に local stateとして管理させる方法もありましたが、シンプルさ重視で Vue Composition API の reactive を利用した簡易的なストアを作成しました。

👍 良かったこと

リフレッシュや多重リクエスト制御が自然といい感じになる

複数コンポーネントが Apollo Client のクエリ・ミューテーションと連動して自動的に再描画されていくのはなかなか便利です。

独自でクエリ発行→再描画を制御している場合、適宜処理の影響を受ける箇所でリフレッシュが必要になりますが、Vue Apollo がそのあたりを吸収してくれて、とても簡単に実装できました。

また、データが必要な箇所ではあまり深く考えずにクエリを実行しても、Apollo Client側で多重リクエストは自動的に抑制して捌いてくれます。(※設定によります)

開発時の「こうやって動いてくれたらいいのにな〜」という思いを、ほどよく汲みとって動作してくれるのは体験として良かったです。

reactive での簡易ストアは楽だった

reactive での簡易的な状態管理を作成しましたが、単純なオブジェクトを変更・参照していく形なのでとてもシンプルでした。TypeScriptとの相性も良かったです。最低限のガードとして参照側は Readonly 型としていたため、意図しないストア書き換えによる事故も発生しませんでした。

ただし、これは常にベストな選択肢では無さそうにも思います。一定以上の規模感になってくると、独自実装を増やすよりも、Vuexなどを利用して王道に沿った形にしていくほうが、長期的なメンテナンスコストは低くなるかもしれません。

🙈 大変だったこと

テストが書きづらい

Vue Test Utilsを利用したコンポーネントのテストを書く際に、Apollo Client のキャッシュに依存したテストが非常に厄介でした。

graphql-toolsを使うことでモック化自体は容易ですが、キャッシュを前提とした挙動のテストを書く場合、セットアップ段階でキャッシュを生成する必要があります。

ただ、実際にテストを実行していると、「キャッシュが無くてテストに落ちている」というのがエラーから気付くのがなかなか難しく、長時間ハマってしまうことも何度かありました。

暗黙的な挙動が増える&対処が複雑化しがち

Vue Apollo を組み合わせることで「Vueでの値の変更や別の箇所でのクエリ結果に連動して自動的に再描画してくれる」というのは、便利である反面、暗黙的な挙動となる箇所が増えていきます。また、クエリ発行のタイミング制御も難しくなり、画面上で複雑な操作・更新が発生する場合、それを実現するためのコードも同時に複雑になっていく傾向がありました。

Vue Apolloを利用せず、クエリ結果をストアに記録して明示的に制御するように書き換えるほうがシンプルになるかもしれないな..と思っている部分もありますが、もちろん恩恵を受けている箇所もあるため、今後様子を見ての判断になりそうです。

コンポーネント設計&デザイン

Atomic Design

コンポーネント設計は Atomic Design を採用しました。これは、全力で死守するものというより「判断基準を最初に設けたかった」というのが目的です。

以前からMisocaのフロントエンド開発時には、コンポーネントをどういった粒度で切るべきか / どこに配備すべきか、といった点が曖昧で迷ってしまい、フロントエンドチームが相談を受けることも度々ありました。

受注管理の機能開発時も同じ状況に陥ることが予想できたため、予め何らかの基準を設けるのが必要と考えました。

Scoped Style (Vue)

CSSは Vue.js の Scoped Style で書いていきました。

もともとMisocaのデザインは *.scss ファイル内に SCSS で書かれていますが、デザイナーの皆様の尽力もあり、いまではかなり整理されて、ファイル単位での影響範囲が明確になっていました。

tech.misoca.jp

さらに将来的な展望として Scoped CSS を利用した Vueコンポーネント化もあったため、まず手始めに今回の受注管理機能で実践してみた形です。

開発時のルールとしては、外部のCSSとの干渉や無駄な重複を避けるため、

  • Scoped Style を適用するためのクラスにはすべて _ を prefix として付与する
  • 色やフォントサイズ定義は既存の定義をimportして利用する

などがありました。

👍 良かったこと

迷いにくかった

「Atomic Design ベースでいきましょう」と合意をとったことで、当初の目論見どおり、コンポーネントの粒度・配備で迷うことは少なかったです。また、あくまでも判断基準のためで良くも悪くも緩い導入だったので、ルールに縛られすぎずに程よい付き合いができたのも良かった点でした。

CSSの変更が楽だった

Scoped Style ではコンポーネントに対応するスタイルは、当然ではありますが、そのコンポーネントの中に書いてあります。

整理されていたとはいえ、外部化されているCSSを触るときは「これってどこか他を壊しちゃうんじゃ…」という不安が心の片隅にありましたが、Scoped Style のように影響範囲がそのファイル内で閉じているのはなかなかに快適で、修正時の負担がかなり軽減されたように感じます。

🙈 大変だったこと

Scoped Style のスタイルの親子継承が厄介

影響範囲がファイル内で閉じていると書きましたが、実は Vue の Scoped Style には一部例外があり、子コンポーネントのルート要素だけは親要素のCSSを引き継いでしまうケースがあります。

スコープ付き CSS · vue-loader

たとえば、親コンポーネント側で _foo というクラスに対してスタイリングした場合に、子コンポーネントのルート要素に _foo というクラスを付与すると、親コンポーネント側で定義したスタイルが適用されてしまいます。

レイアウト用のスタイリング時に利用できる仕組みのようですが、どちらかというと意図しないCSS適用を引き起こすケースのほうが多かったです。

調べた限りではこれを無効化する仕組みは無いようで、コンポーネントのルート要素に Scoped Style 用のクラスを付与する場合には、できるだけ他で利用されにくい冗長な名前をつけて回避していました。

まとめ

今回は新しくリリースされた受注管理機能で利用した技術の話でした。

今後もずっと使い続けていけそうなノウハウも得られましたし、逆に「もっとこうすればよかったなぁ」という反省も勿論ありました。

いずれにしても試してみない限りは得られることのなかった知見かなと思うので、今後も(将来の開発者に迷惑をかけない範囲で)新しいことにチャレンジしていけるといいな〜と思います。