every Tech Blog

株式会社エブリーのTech Blogです。

複数クライアント向けアプリのカスタマイズのつらみと向き合う

はじめに

DELISH KITCHEN 開発部で小売向き合いの開発に携わっている野口です。主に Flutter でのアプリ開発を担当しています。

弊社では retail HUB という小売向けのサービスを行っています

https://biz.delishkitchen.tv/retailhub

今回は弊社で開発している retail HUB で作成しているアプリの構成について2つ紹介し、 複数クライアントアプリのつらみとそれぞれのメリット、デメリットを述べたいと思っています。

複数クライアントのアプリ構成とは

上記で紹介している複数クライアントのアプリとは複数のアプリを共通のソースコードで管理するものを想定しています。 イメージとしては、A 社、B 社、C 社に提供する場合、共通項目は使いまわせる構成、アプリ自体は異なる(App Store では別)ので別アプリとして管理できる構成を考えています。また、クライアントごとに独自の機能をカスタマイズすることもあります。

複数クライアントのアプリ構成のつらみ

複数クライアントのアプリ構成には今回のブログのテーマでもあるカスタマイズのつらみがあり、かなり厄介なものになります。 主につらみポイントとしては

  • カスタマイズ要素が増えれば増えるほど管理が大変になる
    • クライアントによってカスタマイズを出しわけないといけない
    • 契約終了とかになったら、使用しない機能を削除するかとか

になります。コードの管理が大変なんです。一部のクライアントではここは使うけどここは使わないとか。クライアントが数十社とかになったら大変になりそうです。

ということで、以降で2つの構成をざっくり解説してメリット、デメリット話します。 先に結論を話すと筆者的には Melos がいいかなと思ってます。

弊社のアプリ構成例

make を使用したアプリ構成

make を使用したアプリ構成は共通のコードから各クライアントに出し分けるイメージです。

make で CHAIN というクライアントの識別子の変数を指定することでクライアントの出し分けを行います。

Makefile

CHAIN := app_A

.PHONY: run
run: chain-switch
    flutter run

.PHONY: chain-switch
chain-switch:
    ./main $(CHAIN)

make runを実行できます。

クライアントを出し分けるコマンドはmake run CHAIN=app_BCHAIN=の後ろが識別子となります。

make runコマンドには以下が指定されており順番に実行されます。実際に出し分けを行う処理はchain-switchで行っており以降で解説します。

  • chain-switch : 渡された CHAIN に応じて出し分けを行う
  • flutter run : ビルドが実行されるコマンド

chain-switchではCHAINに応じて出し分ける処理を行っています。出し分ける内容はconfig.jsonファイルで設定します。 config.jsonファイルでクライアントごとにアプリ名、bundleId,カラーなどその他(他にもたくさんありますが、省略してます)の項目を設定します。

config.json

{
  "app_A": {
    "chainCode": "app_A",
    "appName": "APP A",
    "bundleId": "app_A.app",
    "colors": {
      "primaryMain": "FF9800"
    }
  },
  "app_B": {
    "chainCode": "app_B",
    "appName": "APP B",
    "bundleId": "app_B.app",
    "colors": {
      "primaryMain": "FF8800"
    }
  }
}

取得したCHAINに応じてconfig.json の項目をmain内でshellを使って出し分けています。

main

chain=$1 //chainの取得
chain_config_path=config.json 

chain_config=$(cat config.json | jq --exit-status '.'$chain) || handle_no_chain_error
chain_code=$(echo $chain_config | jq -r '.chainCode')
app_name=$(echo $chain_config | jq -r '.appName')
bundle_id=$(echo $chain_config | jq -r '.bundleId')

template_paths=(./ios/Flutter/Release.xcconfig.template
                ./lib/ui/theme/color.dart.template)

replace () {
    template_file=$(cat $1)
    template_file=$(echo "$template_file" | sed -e "s/###CHAIN###/$chain/g" \
                                                -e "s/###APP_NAME###/$app_name/g" \
                                                -e "s/###BUNDLE_ID###/$bundle_id/g" \
                                                -e "s/###COLORS_PRIMARY_MAIN###/$colors_primary_main/g" \

    echo "$template_file" > ${1%.*}
}

for path in "${template_paths[@]}"
do
    replace $path
done

少し解説を行うと、

ここで chain と config.json の key が一致するものを取得しています。

chain_config=$(cat config.json | jq --exit-status '.'$chain) || handle_no_chain_error

chain が app_A だと以下が取得できる感じですね。

config.json

{
  "chainCode": "app_A",
  "appName": "APP A",
  "bundleId": "app_A.app",
  "colors": {
    "primaryMain": "FF9800"
  }
}

続いて、上記で取得した json を各項目を取得して変数に格納します。

main

chain_code=$(echo $chain_config | jq -r '.chainCode')
app_name=$(echo $chain_config | jq -r '.appName')
bundle_id=$(echo $chain_config | jq -r '.bundleId')

出し分ける対象のファイルのパスを template ファイルを作成し定義します。

main

template_paths=(./ios/Flutter/Release.xcconfig.template
                ./lib/ui/theme/color.dart.template)

ちなみに template ファイルはこんな感じです。

color.dart.template で###COLORS_PRIMARY_MAIN###部分を変数にして切り替えられるようにしています。

import 'package:flutter/material.dart';

class AppColor {
  static const primaryMain = Color(0xFF###COLORS_PRIMARY_MAIN###);
}

対象のファイルの変数を置換します。 置換後の内容を、元のファイル名から .template 拡張子を取り除いた名前の新しいファイルに保存します。 color.dart.template は color.dart のファイルが作成され、実際にはこちらが使用されます。

replace () {
    template_file=$(cat $1)
    template_file=$(echo "$template_file" | sed -e "s/###CHAIN###/$chain/g" \
                                                -e "s/###APP_NAME###/$app_name/g" \
                                                -e "s/###BUNDLE_ID###/$bundle_id/g" \
                                                -e "s/###COLORS_PRIMARY_MAIN###/$colors_primary_main/g") //置換

    echo "$template_file" > ${1%.*} //ファイル作成
}

for path in "${template_paths[@]}"
do
    replace $path
done

出しわけの設定の際にconfig.json、templete,mainにそれぞれに出し分ける項目を記述しないといけないのでかなり手間がかかり大変ですね。。

また、実際の出しわけ処理はshellで行っていますが、この処理はわかりずらいかなと思っています。 私はshellがあまり詳しくないのもあると思いますが、新しくプロジェクトに参画するメンバーが出しわけの処理を理解するのに時間がかかってしまうのではと感じています。

カスタマイズは config.json に項目を追加する形になります。

つまり、カスタマイズが増えれば custom1 のようにフラグや何かしらの値が入るようになります。

config.json

{
  "app_A": {
    "chainCode": "app_A",
    "appName": "APP A",
    "bundleId": "app_A.app",
    "colors": {
      "primaryMain": "FF9800"
    },
    "custom1": "true"
  },
  "app_B": {
    "chainCode": "app_B",
    "appName": "APP B",
    "bundleId": "app_B.app",
    "colors": {
      "primaryMain": "FF8800"
    },
    "custom1": "false"
  }
}

Melos を使用したアプリ構成

Melos を使用したアプリ構成を紹介します。

https://melos.invertase.dev/

Melos についてはこの記事が詳しいので気になる方はこちらから

https://zenn.dev/altiveinc/articles/melos-for-multiple-packages-dart-projects

Melos の構成は各クライアントのアプリから共通のコードを呼ぶイメージです。

以下のように packages の配下に各クライアント向けのアプリ(app_A、app_B、app_C)と各クライアントに対して共通で使用する項目(common)を呼ぶようにしています。

カスタマイズは各クライアントのアプリに格納します。つまり、app_A のカスタマイズは app_A のアプリ内のみで管理することになります。

例えば、クライアントが app_A である場合、app_A から common を呼びます。

packages/app_A/lib/main.dart

void main() {
  runMartApp(config: getConfig());
}
Config getConfig() => Config(
  color: appColor,
  chainConfig: ChainConfig(chainCode: 'app_A'), //クライアントを識別するための文字
);
// appColorでクライアントごとにカラーコードが指定できる
final appColor = AppColor(
  primaryMain: const Color(0xFFFF9800),
  primaryDark: const Color(0xFFFF6D00),
  primaryLight: const Color(0xFFFFB74D),
   // 以下省略
);

main()はビルド時に必ず呼ばれるものであり、runMartApp()を呼びます。runMartApp()は common で定義されているものなので、ビルド時に getConfig()で取得した設定情報とともに共通部分を呼ぶようにしています。

packages/app_A/lib/main.dart

void main() {
  runMartApp(config: getConfig());
}

getConfig は Config というアプリの設定情報を定義しています。これによってクラアントごとに共通分の出し分けを行います。 今回は例で appColor というアプリのテーマカラーの設定情報しか取得していませんが、他にも環境情報など出し分けが必要な項目を設定します。

packages/app_A/lib/main.dart

Config getConfig() => Config(
      color: appColor,
      chainConfig: ChainConfig(chainCode: 'app_A'), //クライアントを識別するための文字
    );
// appColorでクライアントごとにカラーコードが指定できる
final appColor = AppColor(
  primaryMain: const Color(0xFFFF9800),
  primaryDark: const Color(0xFFFF6D00),
  primaryLight: const Color(0xFFFFB74D),
   // 以下省略
);

呼ばれた共通部分のrunMartApp()でアプリを実行します。

packages/common/lib/entrpoint.dart

void runMartApp({
  required Config config,
}) {
   // アプリの実行
    runApp(
      UncontrolledProviderScope(
        container: await setupProviderContainer(config: config), 
        child: const Application(),
      ),
  );
}

取得した config はsetupProviderContainerによって configProvider という設定項目を状態管理するものに渡して上書きします。 これによって、他の画面でも設定情報を取得することができるようにしています。

Future<ProviderContainer> setupProviderContainer({
  required Config config,
}) async {
  final container = ProviderContainer(
    overrides: [configProvider.overrideWithValue(config)],
  );
  return container;
}

ちなみに、こんな感じで状態管理の configProvider を定義しています。

final configProvider = Provider<Config>(
  (ref) => throw UnimplementedError(
    'could not read config, you should set config before read this.',
  ),
);

class Config {
  Config({
    required this.color,
    required this.chainConfig,
  });
  final AppColor color;
  final ChainConfig chainConfig;
}


class ChainConfig {
  ChainConfig({
    required this.chainCode,
  });

  final String chainCode;
}

Melos を使用しなくてもこちらの構成はできますが、Melosを使用するとパッケージ間のバージョンが異なっても動かせるため、各クライアントのカスタマイズ部分と共通で使用する項目(common)で使用するパッケージのバージョンを頑張って合わせる必要がなくなり、運用が楽になります。例えば、commonのバージョンを上げた場合、他クライアントのバージョンはそのままでも動くため気兼ねなくバージョンを上げることができます。

逆にバージョンを統一した場合はcommonでバージョンを上げた際に他クライアントに影響が出る可能性があり、コードの修正をしないといけないかもしれません。 commonは共通項目でメインで開発する部分なのでバージョンをかなりの頻度で上げていきたいですが、他クライアントに影響があるとバージョンを上げるのに躊躇してしまうと思います。クライアントが増えたら、バージョンの影響範囲がかなり広くなりそうなのでバージョンは各クライアントで管理したいですね。

また、CI/CD のサポートあるのでクライアントのビルドや配布の自動化もできるのでそれも Melos を使用するメリットかなと思います。

双方を使用しての感想

make と Melos を使用して感じたことをまとめたいと思います。

make

メリット

  • カスタマイズを他クライアントでも流用できる
    • カスタマイズしたものではあるが、設定項目などを変えれば同じロジックのものをそのまま使える

デメリット

  • カスタマイズ要素が増えるとコードが複雑になるためカスタマイズ要素の管理がつらくなる
    • クライアントの追加、削除などで使ってないけどコードはあるみたいことが起きそう、コードを削除するにも他のクライアントで使いそうな場合など判断が難しくなる
  • 出しわけのためにconfig.jsonの記述、mainの記述、template ファイル作るのは手間
    • 手法としては特殊なので他メンバーのキャッチアップに時間がかかる
  • カスタマイズする際に他のクライアントも同じソースなので影響が出る可能性がある
    • テストの工数が増える

Melos

メリット

  • カスタマイズ要素の管理が楽
    • カスタマイズは各クライアントのアプリの中に閉じ込める(common には記述しない)ことでカスタマイズ要素の管理をしなくて良くなる
  • 他クライアントの機能に影響が出ない
    • カスタマイズは各クライアントのアプリの中に閉じ込めるので他クライアントに影響が出なくなる

デメリット

  • カスタマイズを他クライアントに流用したい場合、実装が必要(コピペはできるので工数は減らせる)

結論

カスタマイズの管理が楽な Melos の方が良いのではないかと思います。 カスタマイズがそのまま流用できないというデメリットはありますが、一番のコード管理の問題が解決できるのでメリットの方が大きいかなと。 ただ、直近は Melos はあまり運用しておらず、 make を主に運用しているためつらみを多く感じているだけで、Melos で気づいていない他のデメリットがあるかもしれません。 よき構成が見つかればまた記事にしたいと思います。

最後までご覧いただき、本当にありがとうございました。