🔩

後方互換性を保つと開発が楽になる事に気づいた

2021/03/25に公開

こんにちはハトです。最近の気づきを記事にしました。ここで言う互換性とは後方互換性のことです。皆さんからの意見お待ちしています。

追記: 2021-06-19
こちらのgoogle cloudのDevOpsに関するドキュメントが非常によくまとめられているので、こちらを読んだほうがいいですね^^
https://cloud.google.com/architecture/devops/devops-tech-database-change-management?hl=ja

互換性のメリット

互換性を考えるときに、2つの部分に対して適応できる。

  1. 境界(インターフェイス)における互換性
  2. 境界内部における互換性

境界(インターフェイス)における互換性

webアプリケーションを考えたとき代表的な境界は次の2つ。

  • DB-webアプリケーション間(データ)
  • webアプリケーション-フロントエンドSPA間(API)

いわゆるスキーマがかけるところ。

境界(インターフェース)の互換性があれば次のメリットがある。

  • データの互換性があれば、DBを変更したあとのバックエンドの修正が非同期でもよい。マイグレーションが非同期に実行できる。
  • 今後モバイル版対応などで、複数バージョンを同時に運用していてもデータの互換性があれば1つのDBで対応できる。
  • APIの互換性があればバックエンドを変更しても、フロントエンドの修正が非同期でもよい。

境界内部における互換性

webアプリケーションを考えたとき代表的な境界内部は次の2つ。

  • webアプリケーション内のメソッドやサービスなど
  • フロントエンドSPA内でのコンポーネント

境界内部の互換性があれば次のメリットがある。

  • バックエンドにおけるメソッドやサービス、フロントエンドにおけるコンポーネントの互換性があれば、他の開発者がそのメソッドやコンポーネントが壊れる(なくなる)という心配なしにチーム開発できる。
  • 互換性を保たない開発と比べて今までのテストが壊れにくい。

互換性のデメリット

互換性を保つということは破壊的なことができないということなので次のデメリットが存在する。

  • ドメインモデルなどを利用していると、既存の言葉が間違っていた際にrenameしたくなる。しかしrenameは破壊的であり互換性を壊してしまうのでできない。
  • 良くない設計を維持してしまう。理想と現実を埋めるためのコードがごちゃごちゃする。
  • イテレーションサイクルで学習した結果いまの形が間違っているとわかっても素早く反映できない。

互換性を保つメリットよりも保たないメリットのほうが上回れば破壊的変更をすべきだと思う。

互換性を保った変更のしかた

データ(DB)の場合

基本的にfast-forward的な感じで追加であれば互換性を壊さない。

  • カラムの追加
  • enum型カラムに対する値の追加

逆に以下は互換性を壊す

  • カラムのrename
  • カラムの削除
  • カラムの属性変更
  • enum型カラムの値の変更

ユーザーテーブルが次のようであった場合

status... ACTIVE, PENDING, TEMPORARY

id name status
1 エレンイェーガー ACTIVE
2 ミカサアッカーマン PENDING
3 アルミンアルレルト TEMPORARY

次のような変更は互換性がない

カラムの属性を変更(idを文字列に変更)

id full_name status
'1' エレンイェーガー ACTIVE
'2' ミカサアッカーマン PENDING
'3' アルミンアルレルト TEMPORARY

カラムのrename(nameをfull_nameに変更)

id full_name status
1 エレンイェーガー ACTIVE
2 ミカサアッカーマン PENDING
3 アルミンアルレルト TEMPORARY

アプリケーション側でいままでnameとして取得していた部分が壊れる

カラムの削除(statusの削除)

id name
1 エレンイェーガー
2 ミカサアッカーマン
3 アルミンアルレルト

アプリケーション側でstatusに依存しているコードがあればこわれる。

カラムの分解(nameをfirst_nameとlast_nameに変更)

id first_name last_name status
1 エレン イェーガー ACTIVE
2 ミカサ アッカーマン PENDING
3 アルミン アルレルト TEMPORARY

アプリケーション側でいままでnameとして取得していた部分が壊れる

enum型の属性の破壊的変更(statusのACTIVEをUSINGに変更)

id name status
1 エレンイェーガー USING
2 ミカサアッカーマン PENDING
3 アルミンアルレルト TEMPORARY

アプリケーション側でいままでstatus ACTIVEに依存する部分が壊れる

次のような変更は互換性がある

カラムの追加(ageを追加)

id name status age
1 エレンイェーガー ACTIVE 19歳
2 ミカサアッカーマン PENDING 19歳
3 アルミンアルレルト TEMPORARY 19歳

enum型の属性の値の追加(statusにUNPAIDを追加し既存のstatusはいじらない)

id name status
1 エレンイェーガー ACTIVE
2 ミカサアッカーマン PENDING
3 アルミンアルレルト TEMPORARY
4 ライナーブラウン UNPAID

アプリケーション側がACTIVE, PENDING, TEMPORARYのみを認識して処理していた場合、statusがUNPAIDのライナーはそれらの処理には引っかからない。しかしアプリケーションは壊れない。

APIの場合

データの互換性と同様に基本的にfast-forward的な追加であれば互換性を壊さない。

  • bodyに対する要素の追加
  • enum型要素の値の追加
  • エンドポイントの追加

逆に以下は互換性を壊す

  • エンドポイント(URL)のrenameや1つのAPIの複数分割
  • bodyの要素のrenameや属性変更
  • bodyの要素の削除
  • enum型要素の値の変更
  • ヘッダーの要素の値の変更

データ型とほとんど同じなので例を1つ出してあとは省略。

APIが次のようであった場合

GET /users
responose body

[
{
  "id": 1,
  "name": "エレンイェーガー",
  "status": "ACTIVE"
},{
  "id": 2,
  "name": "ミカサアッカーマン",
  "status": "PENDING"
}
]

次のような変更は互換性がない

エンドポイントのrename(usersをuser-indexに変更)
GET /user-index
responose body

[
{
  "id": "1",
  "fullName": "エレンイェーガー",
  "status": "ACTIVE"
},{
  "id": 2,
  "name": "ミカサアッカーマン",
  "status": "PENDING"
}
]

次のような変更は互換性がある

エンドポイントの追加(userIdを追加)
GET /users/:userId
responose body

{
  "id": "1",
  "fullName": "エレンイェーガー",
  "status": "ACTIVE"
}

コンポーネントの場合

機能トグル開発をすれば互換性を維持しつつ開発できて便利。

以下はReactの例です。例として、親のページコンポーネントがリストコンポーネントを扱っているときを考えます。

import React from 'react'

// 子。使われるコンポーネント
type AwesomeListProps = {
  items: string[];
}
const AwesomeList: React.FC<AwesomeListProps> = ({ items }) => {
  return (<div>
    {items.map(item => (
       <div>Awesome {item} !!!</div>
    ))}
  </div>)
}

// 親
const SimplePage = () => {
  const items: string[] = ['red', 'blue', 'breen'];
  return (<div>
    <h2>リスト</h2>
    <AwesomeList 
      items={items}
    />
  </div>)
}

ここで、何かしらの理由でAwesomeListを作り変えたいなと思ったとします。何かしらの理由とは例えばパフォーマンスなどです。単純にpropsを1つ追加するだけで済むならそれでいいのですが、大幅にAwesomeListを書き換えたいときに機能トグルを使うと便利です。

import React from 'react'

// 子。使われるコンポーネント
type AwesomeListProps = {
  items: string[];
}
const AwesomeList: React.FC<AwesomeListProps> = ({ items }) => {
  return (<div>
    {items.map(item => (
       <div>Awesome {item} !!!</div>
    ))}
  </div>)
}

const GoodPerformanceList: React.FC<AwesomeListProps> = ({ items }) => {
  return (<div>
    {items.map(item => (
       <div>good {item} !!!</div>
    ))}
  </div>)
}

// 親
const SimplePage = () => {
  const items: string[] = ['red', 'blue', 'breen'];
  return (<div>
    <h2>リスト</h2>
    {process.env.USE_GOOD_PERFORMANCE_LIST === 'true' ? (
      <GoodPerformanceList 
        items={items}
      />
    ) : (
      <AwesomeList 
        items={items}
      />
    )}
  </div>)
}

環境変数USE_GOOD_PERFORMANCE_LISTがtrueのときだけ有効になります。このように書くことで、どちらのコンポーネントも同じpropsを渡すように意識できますし、このままpushしても他の人の開発中のコードには影響を与えません。特に影響がないことがわかればAwesomeListをコードから消すことができます。

Discussion