booklista tech blog

booklista のエンジニアリングに関する情報を公開しています。

Android、iOSネイティブからFlutter移行で得た知見

自己紹介

株式会社ブックリスタのプロダクト開発部でモバイルアプリエンジニアをしている城と申します。 現在、iOS/Androidネイティブで実装された電子書籍アプリのFlutterリプレイスを検討しており、課題を調査しFlutterでどこまで置き換え可能かの実証実験を行っています。

Flutter移行のメリット・デメリット

Flutterに移行すると具体的にどういうメリット・デメリットがあるのかおさらいします。

開発効率とスピードの向上

  • コードベースの統一: Android / iOS で同一コードを使用でき、開発・保守工数を大幅削減
  • Hot Reload: リアルタイムでのコード変更確認により、開発サイクルの大幅短縮
  • 豊富なライブラリエコシステム: pub.devの充実したパッケージにより機能実装の効率化
  • スキルセットの集約: Android / iOS の専門知識を Flutter に集中
  • 新メンバーのオンボーディング: 学習コストの削減
  • CI/CD効率化: 単一のビルドパイプラインでマルチプラットフォーム対応

デメリット・制約

  • ネイティブライブラリの制約: 既存のネイティブライブラリがFlutterに対応していない場合の移行が困難
  • プラットフォーム固有機能: カメラ、位置情報等の高度なネイティブ機能へのアクセス制限
  • アプリサイズ増加: Flutter Engineの組み込みによるアプリサイズの増大

Flutter移行戦略の検討

上記のメリット・デメリットを踏まえ、移行を推奨できるケース慎重な検討が必要なケースを整理します。

移行を推奨できるケース

  • 開発チームのリソースが限られている: 単一技術スタックでの効率化メリットが大きい
  • 長期的な開発・保守を重視: コードベース統一による保守性向上を優先
  • マルチプラットフォーム展開を計画: Web・Desktop対応の将来性を活用したい
  • 既存ネイティブライブラリへの依存が少ない: 技術的制約による移行困難が限定的

慎重な検討が必要なケース

  • ネイティブライブラリに強く依存: 既存資産の移行コストが高い
  • 高度なプラットフォーム固有機能が必要: カメラ・位置情報等の複雑な機能を多用
  • パフォーマンス要件が厳しい: アプリサイズやメモリ使用量に制限がある
  • Flutter経験者の確保が困難: 学習コストや採用コストを考慮する必要

しかし、実際のプロジェクトでは理想的な条件が揃わないことも多いです。弊社の電子書籍アプリでも技術的な制約に直面しました。次のセクションでは、具体的にどのような制約があったのか、そしてそれをどう解決したのかを詳しく説明します。


電子書籍アプリで直面したFlutter移行の制約

ビューワーライブラリの制約

電子書籍アプリの移行において、最も大きな技術的制約となったのがビューワーライブラリの問題でした。

  • 使用中のライブラリ: 高機能な電子書籍表示・操作ライブラリ
  • 技術的制限: Activity・ViewController上での使用が前提
  • Flutter非対応: ライブラリベンダーからFlutter版の提供予定なし
  • 継続使用の方針: ユーザーの読書体験を変えないため、同様のライブラリを使用する方針

なぜこの制約が重要なのか

ビューワー機能は電子書籍アプリの核心部分であり、以下の理由から既存ライブラリの継続使用が必要です。

  • ユーザー体験の継続性: 既存ユーザーの慣れ親しんだ操作感を維持
  • 読書体験の品質: 長年培われた最適化されたページめくりや表示性能
  • 機能の安定性: ページめくり、拡大縮小、しおり、検索等の複雑な機能群
  • 開発リスク回避: 新しいライブラリ導入による予期しない不具合を避ける

制約を踏まえた移行判断

この制約により、完全なFlutter移行は不可能となりましたが、ネイティブとのハイブリッドによる移行メリットが十分あると考えました。

求められる具体的なUI構成要件

電子書籍アプリでは、以下の画面構成が必要でした。

  1. 本棚画面: 書籍一覧・設定等の基本機能
  2. ビューワー: 本棚の上に重ねて表示(本棚状態は裏で維持・ネイティブ必須)
  3. ビューワーメニュー: ビューワーの上にメニューUI表示

この構成により、ユーザーは「本棚 → ビューワー → メニュー操作 → ビューワー → 本棚」へと、すべての状態を保持したままシームレスに遷移できます。

UI構成要件:3層アーキテクチャ

ここで疑問が生まれます: ビューワーを挟んで、本棚とビューワーメニューで異なるUIをFlutterで描画できるのでしょうか。それができれば、ビューワーメニューも一度の開発で済み、大幅な開発効率化が実現できるはずです。

複数のUIツリーを保持できるか

執筆時点のFlutter において、1つの FlutterEngine は1つの Widget ツリー(画面階層構造) しか管理できません。これはFlutterのアーキテクチャ制約です。

単一の FlutterEngine では、本棚UIのウィジェットツリーの状態を保持したままビューワーメニューをネイティブ画面の上にオーバーレイ表示できません。 単一のFlutterEngineでネイティブ画面の上にビューワーメニューを重ねようとすると、本棚が表示されてしまうという現象が起きました。 そこで取ったアプローチを次で説明します。

FlutterEngineとは何か

複数Engine構成の解決策を理解するために、まずFlutterEngineの基本概念について説明します。FlutterEngineの理解は、なぜ単一Engineでは制約があり、複数Engineが必要なのかを把握する上で重要です。

FlutterEngineの役割:

  • Dartコードの実行環境: Dart仮想マシン(VM)とアプリケーションの状態・ライフサイクルを管理
  • レンダリングエンジン: UIの描画とアニメーション処理を担当
  • プラットフォーム統合: ネイティブコードとの橋渡し機能

通常のFlutterアプリでは:

  • 1つのアプリケーションに対して1つのFlutterEngineが動作
  • すべてのWidget(UI部品)は単一のウィジェットツリー上で管理

複数FlutterEngine構成とは:

  • 同一アプリ内で複数のFlutterEngineを並行実行
  • 各Engineは独立したメモリ空間と状態を持つ
  • Engine間では直接的なデータ共有ができない

複数FlutterEngine構成による状態分離

  • メインEngine: 本棚画面の状態を継続維持
  • オーバーレイEngine: ネイティブビューワー上で独立してFlutterUIを表示

アーキテクチャ全体構成

複数FlutterEngineによる構成を下図に示します。

ハイブリッド構成アーキテクチャ

このアーキテクチャの特徴を説明します。

ネイティブプラットフォーム上でのFlutterEngine実行

  • 複数Engine管理: ネイティブレイヤーが2つのFlutterEngineを生成・管理
  • Engine間完全分離: 各EngineはDartVM、メモリ空間、状態管理がすべて独立しているため、状態は共有不可

Method Channelによる通信制御

  • Native経由の通信: FlutterEngine同士は直接通信不可、必ずネイティブを経由
  • 双方向インターフェース: Flutter → Native、Native → Flutter の両方向で関数呼び出し
  • Engine制御: ネイティブがビューワーイベントに応じてOverlay Engineを制御

OSレベルでの統一データアクセス

  • プラットフォーム標準ストレージ: SharedPreferences、Keychain、SQLiteなど
  • 全コンポーネント共通: すべてのEngineとネイティブコンポーネントが同一データソースにアクセス
  • データ一貫性: OSレベルでの排他制御により、データ競合を防止

Flutter と ネイティブの役割分担

機能 実装 理由
本棚・設定画面 Flutter UI開発効率とクロスプラットフォーム対応
ビューワー本体 ネイティブ ライブラリ制約
ビューワーメニュー・オーバーレイ Flutter UI統一性と開発効率

Flutter ↔ ネイティブ間の通信基盤

ハイブリッド構成では、Flutter実装とネイティブ実装の連携が不可欠です。 MethodChannel を使用することで、双方向の関数呼び出しとデータ受け渡しを実現します。詳細な実装については後述の「複数FlutterEngine実装の詳細」セクションで説明します。


複数FlutterEngine実装の詳細

前章で複数FlutterEngine構成のアーキテクチャを説明しましたが、実際の実装では「Flutterとネイティブ間の通信」が重要な要素となります。本章では、具体的な実装方法について詳しく解説していきます。

Flutter-ネイティブ間通信の必要性

ハイブリッド構成では、Flutterで実装された画面とネイティブで実装されたビューワーと連携する必要があります。具体的には以下のような通信が発生します。

  • Flutter → ネイティブ: 本棚画面からのビューワー起動
  • ネイティブ → Flutter: ビューワーからのメニュー表示要求
  • データ共有: 読書設定や書籍情報の同期

これらの通信を実現するために、FlutterではMethod Channelという仕組みを使用します。

Method Channelの概要

Method Channelは、FlutterとネイティブOS(Android/iOS)間で双方向の関数呼び出しを可能にする通信基盤です。

Method Channelの特徴

  • 双方向通信: Flutter → ネイティブ、ネイティブ → Flutter両方向の呼び出しが可能
  • 非同期処理: ネイティブ側の処理完了を待つことができる
  • 型安全: パラメータの型チェックと変換を自動処理
  • プラットフォーム統一: Android/iOS共通のAPIで実装可能

基本的な通信フロー

  1. Channel作成: Flutter側とネイティブ側で同じ名前のChannelを作成
  2. ハンドラー登録: 各側で受信した呼び出しを処理するハンドラーを登録
  3. 関数呼び出し: 一方から他方へ関数名とパラメータを送信
  4. 結果返却: 処理完了後、結果を呼び出し元に返却

Method Channelの実装方法

ハイブリッド構成の実装へ入る前に、Method Channelの具体的な実装方法について詳しく説明します。

Flutter側の実装

// Flutter側での基本的な実装
class NativeInterface {
  // MethodChannelに渡すnameパラメータはネイティブと合わせる必要がある
  static const _channel = MethodChannel('methodChannelName');
  
  // Flutter → ネイティブへの呼び出し
  static Future<void> openViewer(String bookId) async {
    await _channel.invokeMethod('openViewer', {'bookId': bookId});
  }
  
  // ネイティブ → Flutter からの呼び出し受信
  static void setupCallbacks() {
    _channel.setMethodCallHandler((call) async {
      switch (call.method) {
        case 'onMenuRequested':
          // メニュー表示要求の処理
          break;
      }
    });
  }
}

Android側の実装

class MainActivity : FlutterFragmentActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        // FlutterEngineにMethodChannelを登録し、Flutterからの呼び出しを受け取る
        // Flutterで定義したnameと同じ名称を指定
        val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "methodChannelName")
        
        // Flutter → ネイティブ からの呼び出し受信
        channel.setMethodCallHandler { call, result ->
            if (call.method == "openViewer") {
                // パラメータ取得
                val castedArgs = call.arguments as? Map<*, *>
                val bookId = castedArgs?.get("bookId") as? String?

                // 本を開く処理
                startActivity(ViewerActivity.newIntent(this, bookId))

                // 結果をFlutterに返却
                result.success(null)
                return@setMethodCallHandler
            }
            result.notImplemented()
        }

        // ネイティブ → Flutterへの呼び出し
        channel.invokeMethod("onMenuRequested")
    }
}

この通信基盤により、Flutterの本棚画面からネイティブビューワーの起動、そしてビューワー上でのFlutterメニュー表示制御が可能になります。

複数FlutterEngine構成の実装アプローチ

前章で説明した通り、単一のFlutterEngineでは本棚の状態を保持したままビューワー上にメニューを表示できません。

解決策:独立した複数Engine構成

  • メインEngine: 本棚画面専用のFlutterEngine(状態を継続維持)
  • オーバーレイEngine: ビューワーメニュー専用のFlutterEngine(独立したUI表示)

この構成により、各Engineが独立した状態を管理し、本棚の状態を保持したままビューワー上にメニュー表示が可能になります。

実装の全体的な流れ

  1. Flutter側エントリーポイント追加: オーバーレイEngine用の起動関数を定義
  2. Android側Engine管理: 複数のFlutterEngineを生成・キャッシュ・制御
  3. UI表示制御: ネイティブ画面上でのFlutterView表示/非表示切り替え

それでは、各ステップの詳細な実装を見ていきましょう。

Flutter側エントリーポイントの追加

main.dartトップレベルでVMのエントリーポイントを定義します。

// エントリーポイント追加
@pragma("vm:entry-point")
void showViewerMenu() {
  runApp(
    MaterialApp(
      navigatorKey: navigatorKey,
      color: Colors.transparent,
      home: ViewerMenu(),
    ),
  );
}
// ここまで

void main() {
  runApp(MyApp());
}

Android側での複数Engine管理

configureFlutterEngine()で渡されるEngineとは別のエンジンを生成し、他のActivityからも参照できるようにキャッシュしておきます。

class MainActivity : FlutterFragmentActivity() {

    // FlutterEngineをキャッシュする名称
    companion object {
        const val ENGINE_GROUP_NAME = "flutter_engine_group"
        const val MAIN_ENGINE_NAME = "flutter_main_engine"
        const val OVERLAY_ENGINE_NAME = "flutter_overlay_engine"
    }

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        // FlutterEngineにMethodChannelを登録し、Flutterからの呼び出しを受け取る
        val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "methodChannelName")
        channel.setMethodCallHandler { call, result ->
            if (call.method == "openViewer") {
                // パラメータ取得
                val castedArgs = call.arguments as? Map<*, *>
                val bookId = castedArgs?.get("bookId") as? String?

                // 本を開く処理
                startActivity(ViewerActivity.newIntent(this, bookId))

                // 結果をFlutterに返却
                result.success(null)
                return@setMethodCallHandler
            }
            result.notImplemented()
        }

        // ここから追加

        // FlutterEngineGroupを作成
        val engineGroup = FlutterEngineGroup(this)
        
        // オーバーレイ用Engineのエントリーポイント設定
        val overlayEntryPoint =
            DartExecutor.DartEntrypoint(
                FlutterInjector.instance().flutterLoader().findAppBundlePath(),
                "showViewerMenu",   // main.dartで追加したエントリーポイントの関数名と合わせる
            )
        
        // オーバーレイ用Engineを実行
        val overlayEngine =
            engineGroup.createAndRunEngine(
                applicationContext,
                overlayEntryPoint,
            )

        // EngineGroup をキャッシュに登録
        FlutterEngineGroupCache
            .getInstance()
            .put(ENGINE_GROUP_NAME, engineGroup)

        // メイン画面用Engine をキャッシュに登録
        FlutterEngineCache
            .getInstance()
            .put(MAIN_ENGINE_NAME, flutterEngine)

        // オーバーレイ用Engine をキャッシュに登録
        FlutterEngineCache
            .getInstance()
            .put(OVERLAY_ENGINE_NAME, overlayEngine)

        // ここまで
    }
}

Engine間でのデータ共有の重要な注意点

複数のFlutterEngineを使用する際の大きな制約として、各Engineは独立したメモリ空間を持つことになります。

問題点:

  • staticな変数やシングルトンインスタンスは各Engine間で共有されない
  • Providerや状態管理ライブラリ(Riverpod、BLoC等)のインスタンスも別物となる
  • メインEngine(本棚)とオーバーレイEngine(メニュー)間で直接的なデータ共有は不可能

解決策:

Engine間でデータを共有する場合は、物理的な永続化ストレージを介する必要があります。

具体的な実装方法については、後述の「ネイティブとFlutterでの共通Preference管理」セクションで詳しく説明します。

この制約により、複数Engine間での状態管理設計には特に注意が必要です。

実際の表示ロジック

Androidでの表示実装:

ビューワーの上へ重ねるようUI構成にFlutterViewを追加し、visibilityで表示/非表示の切り替えを行うようにしてみます。 まずはレイアウトファイルです。画面全体を占めるビューワーUIの上に、メニュー用のFlutterViewを重ねます。

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/viewer_activity"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ViewerView
        android:id="@+id/viewer_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

    <io.flutter.embedding.android.FlutterView
        android:id="@+id/menu_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</FrameLayout>

続いてActivityの実装です。 オーバーレイEngineを使用するように書き換えます。

class ViewerActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // レイアウト読み込み
        setContentView(R.layout.viewer)

        // オーバーレイ用Engineを使用するよう修正
        val overlayEngine = FlutterEngineCache
                .getInstance()
                .get(FlutterMainActivity.OVERLAY_ENGINE_NAME)
        overlayEngine?.let { engine ->
            val flutterView = findViewById<FlutterView>(R.id.menu_view)
            flutterView?.apply {
                setBackgroundColor(Color.TRANSPARENT)
                attachToFlutterEngine(engine)
                visibility = View.GONE
            }
        }
    }
}

2つの独立した Widget ツリーを並行管理することで、ネイティブビューワー上でのFlutterオーバーレイ表示を実現しています。


ネイティブとFlutterでの共通Preference管理

前章で複数FlutterEngineの実装について説明しましたが、Engine間で直接的なデータ共有ができないという制約があります。 この制約を解決するため、SharedPreferences(Android)、UserDefaults(iOS)をネイティブとFlutter双方からアクセスしてデータ共有するアプローチを説明します。

統一アプローチ

ネイティブとFlutterで統一データにアクセスする必要がある場合、Preferenceをどのように扱えば良いでしょうか。

Preferenceストレージの使い分け戦略

// Flutter側での実装例
class PreferenceManager {
  Future<void> saveSamplePreference() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('test_shared_key', 'abc123');
  }
}

Android実装例

key名称にflutter.プレフィックスをつけて呼び出すと、Flutter側でsetした値を取得できます。 iOSのUserDefaultsでも同様です。

class PreferenceManager {
    fun getSamplePreference(context: Context) {
        val pref = context.getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE);
        val refValue = pref.getString("flutter.test_shared_key", null);
        print($refValue);
    }
}

まとめ

Flutter移行を検討している方へのアドバイス

移行前の検討事項

  1. 制約の洗い出し: ネイティブライブラリやプラットフォーム固有機能の依存関係
  2. 段階的な移行戦略: 一括移行ではなく、機能単位での段階的アプローチ
  3. パフォーマンス要件: ユーザー体験を損なわない移行計画

成功のポイント

  • プロトタイプでの検証: 複雑な要件は事前に技術検証を実施
  • チーム内の知識共有: ネイティブとFlutter両方のスキル習得
  • 継続的な最適化: リリース後もパフォーマンスモニタリングを継続

複雑な制約がある場合でも、適切なアーキテクチャ設計により Flutter移行は十分可能です。 ただし今回の場合、FlutterとネイティブAndroid/iOSの知識がエンジニアに求められ、学習コストは高くなります。 本記事が同様の課題に取り組む方々の参考になれば幸いです。


読んでいただき、ありがとうございました。 ご質問やご感想がありましたら、お気軽にコメントください。

新規事業領域の開発におけるAI活用事例についてご紹介

はじめに

株式会社ブックリスタのプロダクト開発部でエンジニアとして開発に携わっている峯岸と申します。
主に新規事業開発室でiOSアプリの「Oshibana」とWebサービス「推しゲーム」などを開発しています。

新規事業開発室ではOshibanaや推しゲームなどのサービスを展開しており、そこでAIを積極的に活用しています。
今回は日々の開発において、また現在試行しているビジネスサイドとの協働において、どのようにAIを利用しているかご紹介します。

新規事業開発の課題

新規事業開発においては、市場の需要を迅速に検証し、リリースサイクルを短縮することが重要です。しかし少人数のチームでは、スピードを優先すると将来の拡張性・保守性が損なわれ、品質を重視すると市場機会を逃すリスクが高まるというジレンマに直面していました。

AIをパートナーとした解決アプローチ

そこで、AIを利用して今までとは異なるアプローチでリリースサイクルを短縮できないか模索していました。従来から活用している開発のAI利用に加え、ビジネスサイドは考えたアイディアの動作を確認するプロトタイプを作り、それをエンジニアに渡して実装する、という協働モデルです。

AIの力を借りることで、少人数でありながらも開発スピードを維持し、かつ一定の品質を保った効率的な開発を実現することを目指しています。また、ビジネスサイドがAIを活用して市場性を検証できるようになることで、エンジニアリソースをより確度の高い開発に集中できます。その結果、リリースサイクルの短縮と拡張性・保守性の向上を両立できるようになると考えています。

利用しているAI

新規事業の開発においては次のAIを主に利用しています。

  • VSCode Copilot
  • Cursor
  • Devin
  • Claude Code

開発現場でのAI活用事例

設計、実装、レビュー、バグ分析など、開発プロセス全体でAIを活用しています。
設計フェーズでは、AIに設計のリスクや簡略化の余地などを質問し、過度な作り込みを避けながら技術的負債を最小限に抑えた設計を短時間で検討しています。
実装段階では、Devinなどの非同期型AIに実装を依頼し、その後Cursorなどの対話型AIを使って品質を担保しながら仕上げを行っています。
レビュー工程では、Claude Codeでプルリクエストの要約を自動生成し、事前定義したチェック観点に基づいた一次レビューを依頼します。
人間のレビュアーはより本質的な設計やロジックの妥当性をレビューします。
バグ分析・修正では、Claude Codeでクラッシュレポートやエラーログを解析し、Cursorで原因調査を効率化し、修正案を短時間で検証できるようになりました。

ビジネスサイドとの協働

これまで開発チームでのAI活用から始まり、現在ではビジネスサイドでも企画段階での壁打ちやプロトタイプ生成にAIを活用するようになってきました。今後は、ビジネスと開発がより一体となって進んでいけるようなAI活用を模索しています。

企画から本番リリースまでの新しい協働モデル

新しい協働モデル

従来、ビジネスサイドが作りたいアイディアを形にするためには、エンジニアのリソースを使う必要がありました。しかしAIの進化により、ビジネスサイドが自らプロトタイプを作成できるようになりました。この変化により、ビジネスサイドが考えるイメージに近いものをエンジニアが実装できるようになり、プロトタイプがあることで「こういうものを作りたい」という意思疎通が格段に行いやすくなりました。その結果、開発の方向性が明確になり、リリースサイクルが短縮され、より迅速に市場に価値を届けられるようになりました。

具体的な協働の流れ

実際の開発では、以下のような流れでビジネスサイドとエンジニアが協働しています。ここでは、推しゲームの中で最近リリースした推し占いを実例として、この協働モデルがどのように機能したかをご紹介します。

推しゲームは各ゲームをマイクロサービスとして実装しており、それらを組み込む形でサービスが成り立っています。このアーキテクチャにより、各ゲームを独立して開発・デプロイでき、新規ゲームの追加や既存ゲームの更新が容易になっています。推し占いもこのマイクロサービスの1つとして実装されました。

ビジネスサイドによるプロトタイプ作成(推し占いの例)

推し占いの開発では、まずビジネスサイドがAIを活用してスタンドアロンで動くようなモック的なプロトタイプを作成しました。このプロトタイプにより、アイディアが実際にどう動くかを確認でき、動作イメージを明確にできました。この時点では既存のアプリ、サービスとの整合性はあまり考慮せず、とにかく動くものを作成することを優先しました。

ビジネスサイドは、AIに対して「推し占いのゲームを作りたい」というアイディアを、画面の構成や機能の要件として伝えました。AIはその要件を元に、HTMLやJavaScriptで動作するプロトタイプを生成します。このプロセスで、ビジネスサイドは自分のアイディアが実際に動く様子を確認でき、必要に応じて何度でも修正を繰り返して理想の形に近づけていきます。

ビジネス側が作成した、実際に作成された仕様書の一部

この作業を通じて、ビジネスサイドからAIに渡す仕様書が出来上がります。この仕様書には、画面の構成、機能の詳細、ユーザーの操作フローなどが含まれており、以降はエンジニアも参照しながら作業を進めていきます。プロトタイプがあることで、ビジネスサイドとエンジニアの間で「こういうものを作りたい」という共通認識が生まれ、意思疎通が格段にスムーズになります。

Before(プロトタイプ)

After(本番)

プロトタイプと本番実装を比較すると、基本的な画面構成や機能はプロトタイプのイメージをそのまま反映しています。一方で、既存の推しゲームのデザインシステムに合わせたUIの調整や、実際のデータベースとの連携、エラーハンドリングなど、本番環境で必要な要素が追加されています。プロトタイプがあることで、ビジネスサイドが求めている機能やUIのイメージが明確になっているため、実装の方向性がぶれることなく効率的に開発を進められました。

ビジネスサイドと開発側の取り決め

推し占いの開発でこの協働モデルをスムーズに進めるため、ビジネスサイドと開発側で以下の取り決めを行いました。これら取り決めはビジネスサイドでプロトタイプを作成する際AIに渡され、推し占いのプロトタイプ作成時も適用されました。

これらの取り決めの多くは開発を速やかに行うための制約です。AIのプロンプトへ組み込むことで、ビジネスサイドが意識せずとも自動的にこの制約を守れるような状況を作り出しています。これにより、ビジネスサイドはアイディアの実現に集中でき、開発側も効率的に実装を進められるという、双方にとってメリットのある協働が実現できています。

取り決め 説明
プロトタイプはスタンドアロンで動くこと プロトタイプは既存のシステムに依存せず、単独で動作するように作成する。これにより、ビジネスサイドは自分の環境で自由にプロトタイプを確認・修正でき、エンジニアもプロトタイプの動作を独立して検証できる。
サーバー連携部分はモックで実装を切り替え可能にすること データの永続化やGoogle Analytics(GA)などのサーバー連携が必要な部分については、モックを利用して実装を切り替えられるようにする。これにより、エンジニアはプロトタイプの動作確認を容易に行え、実環境への組み込み時もモックから本番実装への切り替えがスムーズに行える。
プロトタイプのアーキテクチャは統合先にできるだけ合わせること プロトタイプを作成する際、統合先のアーキテクチャ(推しゲームの場合はマイクロサービスアーキテクチャ)にできるだけ合わせるようにAIに指示を行う。これにより、エンジニアがプロトタイプを本番実装に変換する際の作業量が減り、既存システムへの組み込みがスムーズに行える。例えば、コンポーネントの構造やデータの流れを統合先のアーキテクチャに近づけることで、実装時の変換コストを最小限に抑えられる。

これらの取り決めにより、開発側のプロトタイプ動作確認と実環境への組み込みが行いやすくなり、ビジネスサイドとエンジニアの協働がより効率的になりました。

エンジニアによる本番実装(推し占いの例)

ビジネスサイドが作成した推し占いのプロトタイプと仕様書を受け取ったエンジニアは、これを基に本番環境での実装を進めました。

まず、エンジニアは、ビジネスサイドが作成したプロトタイプと仕様書を参考に、本番環境での実装に必要な技術的な仕様書を作成しました。エンジニア主導で必要に応じてビジネスサイドとコミュニケーションをとりながら、データモデルの設計、APIの仕様、既存システムとの統合方法などを検討できました。ビジネス要件と技術的な制約の両方を考慮した議論がしやすくなり、破綻のない構造を維持できました。

開発側が作成した、実際に作成された仕様書の一部

その後、エンジニアはこのプロトタイプと仕様書をベースに、バックエンドや既存機能とのマージを行いました。推しゲームのマイクロサービスアーキテクチャにより、推し占いゲームは独立したサービスとして実装され、既存の推しゲームプラットフォームに組み込まれました。

推し占いのバックエンドについてはデータモデルと、プロトタイプのモックを参考にAIを利用して実装しました。フロントについても同様にプロトタイプのモック実装を本実装に切り替え、バックエンドに接続するような指示をAIに与えて実装しました。ビジネスサイドの仕様書とエンジニアの仕様書を両方参照することで、ビジネス要件と技術要件の両方を満たした実装が可能になりました。

ビジネスサイドにとっての効果

この協働モデルにより、ビジネスサイドは以下のような効果を実感しています。

  • アイディアの検証が迅速に:エンジニアのリソースを待たずに、自分でプロトタイプを作成してアイディアを検証できるようになった
  • 意思疎通の質が向上:プロトタイプにより、ビジネスサイドとエンジニアの間で「こういうものを作りたい」という共通認識が生まれ、詳細な仕様のすり合わせが効率的になった
  • 市場への投入速度が向上:プロトタイプ段階で動作イメージが明確になっているため、実装の方向性がぶれることなく、迅速に市場に価値を届けられるようになった

今後の展望

企画から開発、運用まで、ビジネスとエンジニアリングが境界なくAIをパートナーとして活用し、より迅速で質の高い意思決定と実行ができる組織を作っていきたいと考えています。この「失敗前提で設計されたパイプライン」こそが、新規事業開発におけるAI活用の本質的な価値だと考えています。

また、ビジネスサイドが作成するプロトタイプのデザインについて、既存機能と統一感を取るために、Figmaで作成されたデザインコンポーネントからAIを使ってデザインを作れるような仕組みを考えています。これにより、プロトタイプ段階から既存サービスとの統一感を保ったデザインを生成でき、実装時のデザイン調整のコストを削減できるようになると期待しています。

“プロジェクトを前に進める役”をやってみて気づいたこと

1. はじめに

初めまして、コミック ROLLY サービスでサーバーサイドエンジニアをしている伊藤です。
今回私からは、「今までチームリードや PM の経験はなかった人間が、“プロジェクトを前に進める役”をやって気づいたこと」についてお伝えします。

コミック ROLLY の開発チームは、スクラム開発を導入しています。
PM という役職自体はないものの、メンバー各自が自律的に動くことをよしとしているチームです。

その中で今までチームリードや PM の経験がなかった私ですが、今回ご縁があり、現在のチームでリーディングの機会をいただきました。
このチームでリーディングのためにどういう動きをしたか、動いてみて気づいたことお話ししていきます。

2. コミック ROLLY のサービスと PM をしたプロジェクトについて

「コミック ROLLY」とは、株式会社ソニー・ミュージックエンタテインメント から配信している Android, iOS のスマートフォンアプリケーションです。

様々なコミックスを無料で読めたり、アプリを通して課金してコミックスを購入できるなどの機能があり、書店で売られているコミックスほか、先行配信するコミックスなどもそろっているサービスです。

今回のプロジェクトは、コミック ROLLY で一定期間課金せずに読むことができる「期間限定無料」というコミックス商品に対応するというものでした。

コミックROLLYの商品の流れ

コミック ROLLY が商品をアプリに出すまでが以下の流れになっています。

① 社内別チームが担当する商品管理システム(取次システムと呼んでいます)からコミック ROLLY の商品取り込みサーバーに商品情報を連携する。
② 商品取り込みサーバーが連携を受け取ったら、コミック ROLLY の DB に情報を登録する。
③ コミック ROLLY のコンテンツ(アプリに出す特集情報など)管理から、商品と特集などの紐付けを行う。
④ アプリと通信する webAPI を通して DB から情報を取得、整形する。
⑤API から情報を受け取ったアプリが商品情報を表示する。

このプロジェクトは新しい商品をアプリに出すことが目的になるので、①~⑤ に関わる開発が必要になります。
あわせて、商品管理システムのチーム側もコミック ROLLY に対応するための開発が必要になるので、足並みをそろえての開発・リリースをする必要もありました。

3. なぜ自分が PM 役をやることになったのか

コミック ROLLY チームでは スクラム開発という短期間で計画・作業・リリース・振り返りを繰り返す開発手法を採用しています。

プロジェクトを短期間で回す単位(スプリント)を 2 週間で回るように設定していて、タスクもその間に対応できる規模のものがほとんどになります。
ですが、まれに要望が複雑で複数システムの開発が必要なタスクもありました。

そのようなタスクが出てきた場合、要件整理・進捗の見通しなど「誰かが主導して動かす」必要がありました。

当時チーム内で一番所属経歴が長く、ドメイン知識や技術的な理解が深かったため、自然と自分がその役割を担うことになりました。

4. "PM 的な役割”でやったこと

プロジェクト推進役を実施するにあたり、主に以下の対応を重点的に行いました。

1.明確な方針の元の要件・仕様の整理とステークホルダー(PO や他チーム)とのコミュニケーション

中・大規模開発において重要なのは、要望を出すステークホルダー、ステークホルダーと開発メンバーの仲介であるプロダクトオーナーと一緒に要望の認識を合わせていくことです。

チームの設立時から「最低限の開発でユーザーへの提供スピードを優先する」という方針をもとに開発してきており、プロダクトオーナー、ステークホルダーとも方針を共有できていました。

この方針を元に要件・要望を整理し、最低限の機能開発で収めてリリースすることを確定できました。

2.仕様漏れ、変更に対しての柔軟に対応

今まで対応していたプロジェクトやチームでは仕様確定 → 開発 → テスト → リリースの流れが決まっていて、テスト時に仕様の考慮漏れがあった場合の修正やリリース時期の調整に時間がかかりました。

先に記載していますが、コミック ROLLY では 2 週間サイクルで開発を進めているので、中・長規模なプロジェクトも 2 週間で実施できる程度にタスクを細分化しています。そのなかで仕様の整理と開発を並行して行い、都度テストして確認していく方法を取りました。

この対応の結果テストで仕様の考慮漏れに気づけて次のスプリントで修正対応ができるので、大きな手戻りの発生がなく、リリースもリスケせずに対応できました。

3.定期的な振り返りで声を上げやすい環境を作る

スクラム開発ではレトロスペクティブというイベントがあり、プロジェクトに対しての振り返りを行います。私たちはプロジェクト以外にも開発プロセス内の振り返りも行っていました。

レトロスペクティブ中にプロセスの変えたいことをメンバー同士出し合い、アプローチ方法の検討し、次のスプリントにプロセス変更を取り入れる流れで改善案を迅速に取り入れる仕組みを導入しました。

この対応で改善の提案と実行のサイクルを早めたことで「自分の意見でチームが動いている」実感を得やすい形になりました。

その結果、プロジェクトの進行への取り組み、仕様や要件、開発プロセスに対してメンバーそれぞれが自主的に改善意見を言ってくれるようになるなどメンバーの自律性が格段に向上しました。

5. やってみての気づき・視野の変化

今回PM の役割を体験して、以下の気づいた点があります。

① チーム内で声を上げやすい環境にすることの大事さ。
チーム内のコミュニケーション方法を考慮したおかげで、チーム間で声を上げることのハードルが大幅に減りました。
それによってプロジェクト進行中に「リリース手順に考慮漏れがある」「仕様に矛盾点を発見した」などのさまざまな指摘をもらえる機会が多くありました。
おかげで慣れない進行でも進捗が早く、想定した時期に適切な機能をリリースできました。

この状況がないと、リリース直前で仕様の見落としや重大なバグが発見されリリースに大幅な遅れが出る可能性もありました。改めて意見を通しやすい環境があったことの大事さを実感しました。
この気づきとともに「頼れるところはもっと他メンバーにも頼ってよかった」という反省点もありました。

進行上エンジニアのメンバーには決定事項を共有することが多かったのですが、もっと早い過程の段階で意見を聞くことで、メンバーそれぞれの知識を活かせる場面がもっとあったのではと考えています。

② 物事を進めることの大変さ。
プロジェクトの推進役を経験することで、仕様に従うだけではなく、サービスとしてありたい方向、チームの状況に応じて柔軟に対応することがいかに大変かつ重要かも感じることができました。

③「リーダー」は役職じゃなく、チーム状況によって生まれる役割のひとつ。
PMの役割を体験したことで、要件定義や実装の流れを十分理解していれば誰でもチームリーダーとして対応できると考えを改めました。

今回はたまたま私がリーダーでしたが、プロジェクト周りの習熟度で他メンバーをアサインする可能性があったように誰でもリーダーになる機会はあります。

普段からこの感覚を意識すると、関わっているプロジェクトでも開発プロセスに対する積極的な提案や、仕様の曖昧な部分があったときにすぐに確認に入るなどを自主的に考えていけるようになりました。

6. 次にPMをやる時に気をつけること

先の気づきで「決定前の段階で他メンバーにも頼るところがあってよかった」という反省点があったのでそこに対してアプローチしていきたいです。
次にPMをやることがあったら、たとえば設計初期や技術選定の段階でメンバーに相談して考える段階でメンバーを巻き込んで色んな視点を取り入れる対応をすべきだと感じています。

7. 最後に:この経験を通じて

今までリード経験がなかった自分でも、プロジェクトの進行からトラブルの対応まで必要に応じて動くことができました。これは意見を言いやすい環境を作れた上で生じたチーム内での自律的な動きや、メンバー同士で協力できる信頼関係があったからこそだったなと感じています。

メンバー同士が積極的にプロジェクトに関心をもてる状況があれば、メンバー誰でも PM やリーダーの役割を担当できることも重要な気づきになりました。
これからもこの考えを元にエンジニアとして対応していければと思っています。

Reader StoreでAWSアカウント間でのデータ同期をしてみた

こんにちは。プロダクト開発部でクラウドインフラエンジニアとして業務を行っている饂飩です。

今回は、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」の新しい環境を構築した際に発生したAWSアカウント間でのデータ同期についてお伝えします。

新環境検討の背景

新環境検討のきっかけとなった背景としては以下があります。

  • 大きな開発案件では開発や事業部など全体の部署で成果物がどのようなものか認識を合わせるための環境が欲しい。という要望が上がった
  • 検証環境などでは開発者のテストデータや古い作品のデータが多くストアとしてのイメージがつきにくく認識を合わせることが難しくなっていた

今後システム改善をしていく上でも、事業部全体で認識合わせながら進めることが必須事項になってくることもあり、本番相当のデータで可能な環境を用意する運びとなった。

データ同期をどう行うか

弊社のAWSは商用と開発検証でアカウントが異なるため、アカウントを跨いでデータを同期する必要がありました。
大きく以下2つをどうにかする必要がありました。

1. RDS

候補として以下がありました。

export/importはデータ量がそれなりにありデータ同期するとなるとリードタイムがかかってしまうため不採用にしました。
バイナリログレプリケーションはお試し構築で実施していたため最有力候補でした。
ただ、テーブルを絞った上でデータ同期をするため、今後同期するテーブルが増えることを想定して容易に対応できるようにするためDMSを採用しました。

2. S3

候補として以下がありました。

S3レプリケーションは前提条件でバケットのバージョニングを有効にしなければならず、システム影響調査および環境差異が発生してしまうため不採用にしました。
awscliは処理時間がかかってしまうため不採用にしました。
1h程度でデータ同期が完了したのでDataSyncを採用しました。

構築の流れ

今回私が対応した流れとしては以下となります。

RDSおよびS3の同期設定以外

以前のブログで記載しましたが、Terraformコードがあるので、環境設定だけ追加をしてterraform applyを実施するだけなのですぐ完了しました。

RDS

RDSはDMSでレプリケートしますが、初期はソースDBのスナップショットから復元しました。
以下はDMSを利用したデータ同期を行うために実施した作業内容となります。

データ同期を行う環境構成としては以下のイメージのような形となります。
図のaccount-Aが商用のAWSアカウント、account-Bが開発検証用のAWSアカウントとなります。

AWS/DMS

ネットワーク周り

まずはソースDBが別アカウントに存在していたためVPC PeeringでVPC間通信できるようにしました。
その後Route TableやSecurityGroupの設定を実施しました。

DMS

次にDMS環境構築をしました。
実施した内容としては以下の通りとなります。

  • レプリケーションインスタンスの作成
    レプリケーションタスクが動作するインスタンスを作成しました。

  • ソース/ターゲットエンドポイントの作成
    ソースエンドポイント(移行元RDS接続情報)、ターゲットエンドポイント(移行先RDS接続情報)を作成しました。

  • 移行タスクの作成
    上記で作成したレプリケーションインスタンスとソース/ターゲットエンドポイントを紐付けてデータを移行するタスクを作成しました。

S3

S3は空のバケットを最初に作成しました。
DataSyncで実施した内容としては以下の通りです。

データ同期を行う環境構成としては以下のイメージのような形となります。
図のaccount-Aが商用のAWSアカウント、account-Bが開発検証用のAWSアカウントとなります。

AWS/DataSync

DataSync用のIAMロール作成

DataSyncからS3アクセスできるようにIAMロールを作成しました。
ロールに付与したポリシーは以下のような形になります。

ソース用ポリシーサンプル

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:GetBucketLocation",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads"
            ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::bucket-name"
        },
        {
            "Action": [
                "s3:GetObject",
                "s3:ListMultipartUploadParts",
                "s3:GetObjectTagging",
              ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::bucket-name/*"
        }
    ]
}

ターゲット用ポリシーサンプル

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:GetBucketLocation",
                "s3:ListBucket",
                "s3:ListBucketMultipartUploads"
            ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::bucket-name"
        },
        {
            "Action": [
                "s3:AbortMultipartUpload",
                "s3:DeleteObject",
                "s3:GetObject",
                "s3:ListMultipartUploadParts",
                "s3:GetObjectTagging",
                "s3:PutObjectTagging",
                "s3:PutObject"
              ],
            "Effect": "Allow",
            "Resource": "arn:aws:s3:::bucket-name/*"
        }
    ]
}

信頼ポリシーサンプル

{
    "Version": "2012-10-17",
    "Statement": [{
        "Effect": "Allow",
        "Principal": {
            "Service": "datasync.amazonaws.com"
        },
        "Action": "sts:AssumeRole",
        "Condition": {
            "StringEquals": {
                "aws:SourceAccount": "account-id"
            },
            "StringLike": {
                "aws:SourceArn": "arn:aws:datasync:region:account-id:*"
            }
        }
    }]
}

S3バケットポリシー修正

先ほど作成したDataSync用のIAMロールでS3アクセスできるようにバケットポリシーを修正しました。

S3バケットポリシーサンプル

{
  "Version": "2008-10-17",
  "Statement": [
    {
      "Sid": "DataSyncCreateS3LocationAndTaskAccess",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::account-id:role/datasync-role"
      },
      "Action": [
        "s3:GetBucketLocation",
        "s3:ListBucket",
        "s3:ListBucketMultipartUploads",
        "s3:AbortMultipartUpload",
        "s3:DeleteObject",
        "s3:GetObject",
        "s3:ListMultipartUploadParts",
        "s3:PutObject",
        "s3:GetObjectTagging",
        "s3:PutObjectTagging"
      ],
      "Resource": [
        "arn:aws:s3:::bucket",
        "arn:aws:s3:::bucket/*"
      ]
    },
    {
      "Sid": "DataSyncCreateS3Location",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::account-id:role/datasync-role"
      },
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::bucket"
    }
  ]
}

DataSync

次にDataSync環境構築をしました。
実施した内容としては以下の通りとなります。

  • ソース/ターゲットロケーションの作成
    ソースロケーション(移行元S3)、ターゲットロケーション(移行先S3への接続情報)を作成しました。
    ソースロケーションは、アカウントが異なりコンソールからは設定できなかったので、aws cliコマンドを実行しました。
  #aws datasync create-location-s3 \
    --s3-bucket-arn arn:aws:s3:::bucket \
    --s3-config '{
      "BucketAccessRoleArn":"arn:aws:iam::account-id:role/datasync-role"
    }'
  • 移行タスクの作成
    上記で作成したソースロケーションからターゲットロケーションへデータを移行するタスクを作成しました。

データ同期

作成したDMSおよびDataSyncの移行タスクを実行しました。
どちらも初回フル同期して、フル同期後はソース側で変更があれば継続的にレプリケーションする設定となっています。

DMSはタスク実行の際に、ターゲットDB内のテーブルをtruncateして再度ソースDBからフルロードする設定にしているため初回同期は3hほどかかりました。

DataSyncの初回はソースS3の全てのオブジェクトをターゲットS3にコピーするため12hほどかかりました。

直面した課題・反省点

S3関連のコスト面の見積が甘かった

S3上のオブジェクト数からデータ同期する際のDataSync料金(想定データ転送量)を見積りして大したコストにならないとしていました。
だが、移行し始めた翌月のS3コスト(GET、SELECT、他のすべてのリクエスト)が前月差で3倍ほど跳ね上がりました。

原因は、S3同期を毎時やっていたことにありました。
毎時S3オブジェクト同期し、その都度オブジェクト比較するためオブジェクト数2431のリクエストが発生していました。
こちらのコストを下げるべく、同期の頻度を毎時から1日1回に変更することでオブジェクト数*31になり、当初想定していた試算コストくらいになりました。

GuardDutyのコストを見積もっていなかった

GuardDuty S3 Protection機能を有効にしていたのを失念していました。
こちらも移行し始めた翌月のコスト(S3データイベント分析)が前月差30倍ほど跳ね上がりました。

こちらもS3同期頻度を変更することでS3データイベント分析が減ったため前月差2倍程度になりました。

まとめ

今回Reader Storeで新しい環境を構築した件についてお話ししました。
DataSyncを継続的に移行する際にはS3コストにご注意下さい。

新しい環境はまだ未完な状態ではあるので、今後も環境の改善を進めて本番に近い確認ができるようにして、サービスの品質も高めていきたいです。

他にも実施した内容はあるのでどこかでまたお話しできたらと思っています。

以上、読んでいただきありがとうございました。

Reader StoreでSaaS型headlessCMSを使ってみて感じたこと

プロダクト開発部アプリケーションエンジニアの有末と申します。
現在、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」のシステム開発を主な業務として日々取り組んでいます。

Reader Storeでは段階的なサイトリニューアルを進行中です。一部のコンテンツ管理にCMSを利用していますが、リニューアルに伴いCMSの切り替えも行うこととなりました。そこで従来一般的だったフロントエンド一体型のCMS(以降、"従来型CMS"と呼びます。)ではなくheadlessCMSを導入することに。製品はSaaSとして提供されているmicroCMSを利用しました。

本記事では、headlessCMSの導入をした中で感じたことを中心にお伝えしていきます。現在CMSを利用している方や利用を検討している方の参考になれば幸いです。

headlessCMSとは

CMS(Contents Management System)とはWebサイトの構成要素(=コンテンツ。具体的には画像、テキストなど)を管理・配信するシステムを指します。headlessCMSはその中の一種です。バックエンドの機能のみ提供しAPIでコンテンツを配信するCMSをheadlessCMSと呼びます。
パッケージとして提供されている"従来型CMS"と"headlessCMS"、スクラッチ開発するCMSを比較すると以下のようになります。

従来型CMS headlessCMS スクラッチ
概要 コンテンツデータ、管理画面、Webサイトと総合的な機能を提供する。 コンテンツデータ管理といったバックエンドに特化。Webサイト自体の機能は持たない 0から開発をする
開発コスト 低い バックエンド:低い
フロントエンド:高い
高い
自由度 低い バックエンド:低い
フロントエンド:高い
高い
サービス例 WordPress
Movable Type
contentful
microCMS
-

バックエンド・フロントエンドともに提供される従来型では、提供される範囲内では開発に時間をかけずサイトを提供できます。ただし、パッケージの提供範囲外のことを実現しようとするのは難しいです。

スクラッチ開発は全て自分たちの要件に合わせてバックエンド・フロントエンドともに提供できますが、その全てを開発しなければならないため、開発コストは高くなリます。

バックエンドのみを提供しているheadlessCMSでは、バックエンドの開発は提供されている機能で作成をするため、自由度は低いですが開発コストも低いです。フロントエンドはスクラッチ同様、要件に合わせて自由に開発が可能です。裏を返すと、フロントエンドやビューワーは自分たちで用意をしなければなりません。

もちろん一長一短あるので手放しにheadlessCMSが良い、とは言えません。しかし、スクラッチと従来型CMSの良いとこ取りをしたCMSと言えます。Reader Storeでは、バックエンドで独自機能を提供する必要性は薄く、お客さまに提供するフロントエンドへ集中したい、という意図もありheadlessCMSを選択しました。

headlessCMSを使って開発してみた

リニューアルしたお知らせページ

実際にサイトの1機能であるお知らせ機能をリニューアルした時のおおまかな開発の流れが以下です。

  1. PM・デザイナーからの要件受領
  2. 要件をもとにフロントエンドへ渡す項目を決定
  3. headlessCMSで項目を設定
  4. APIが出るので想定通りかテストを実施
  5. APIを利用してフロントエンドを開発

この工程の中でheadlessCMSを導入したことで特筆したいのは2点です。

バックエンドの開発工数の圧縮

まず特筆すべきは項番3"headlessCMSで項目を設定"の工程です。
今回お知らせ機能を作る際に必要なものは"お知らせを入稿する管理画面"と"画面を出すために利用するAPI"でした。これらを作る際に、項番3で行ったことといえば、"2で決まった項目を、画面から設定していく"ことのみです。実際にはCMS上で設定をした後に、"運用は回るのか"、 "フロントで要件を満たしているのか"などの調整をしていたためやりとりや修正の時間は数日かかっています。しかし、その叩き台となるドラフト版を作るだけなら1~2時間もあれば作成は完了していました。

もしスクラッチで開発をしようとしていた場合はどうだったでしょうか。 headlessCMSで必要だったものは"お知らせを入稿する管理画面"と"画面を出すために利用するAPI"でした。スクラッチであればさらにデータを保管するDBの設計なども必要になります。これらを自前で開発しようとした場合、1~2時間ではとても収まらなかったはずです。

短期間で管理画面がすぐ見える形になると、運用部門と実際の画面を見ながらブラッシュアップできます。フロントエンドとの疎通も前倒しにでき、開発時にも実際のAPIで検証しながら開発を進めることができます。この提供スピードが一番のメリットであると感じました。

フロントエンド開発の柔軟性

項番5"APIを利用してフロントエンドを開発"の工程においてもメリットを発揮しました。
段階的にリニューアルを進めているReader Storeでは、既存のCMSで提供している箇所が多くあります。2023年時点でリニューアルを完了していないページでも、今回移行をした"お知らせ"を出している箇所がありました。

部分的に差し替えたページ

もしも従来型のCMSでリニューアルをしていた場合、従来型のCMSではフロントのページは丸々1ページ分、CMSから直接出されているものになります。部分的な対応をしようとするとiframeで埋め込むといった手法をとる形になります。後々リニューアルするページなので作った埋め込みページは丸々破棄になるか、拡張できるよう対処することになるか、いずれにせよのちのリニューアルに向けては検討が必要になっていそうです。

一方で、headlessCMSではAPIを出すのみなので、既存のページで出しているお知らせの箇所を従来の出し方からAPIの呼び先をheadlessCMSにすることで部分的な対応を実現しました。既存ページの改修は必要ですが、APIはリニューアル後も利用できますし、コストや無駄は従来型と比べ低いものになったはずです。

こうした段階的なリニューアルにおいても、headlessCMSではスムーズな移行に寄与しています。

使って感じた点

先述の通り、2点のメリットをheadlessCMSを用いることで実感できました。

  • バックエンドの提供スピード
  • フロントエンド開発の柔軟性

それ以外にもSaaS型のheadlessCMSを利用する中で実感したことがありましたのでご紹介します。

コンテンツ登録や公開日、バージョンなどの管理機能開発が基本不要

CMSにおいて必要な機能ですが、自前で開発をしようとすると大変な機能です。こちらが元から提供されていることで開発の必要がなかったのは非常に助かりました。

一方で、提供されている標準機能では足りない機能が要件としてある場合は実装に考慮が必要です。
Reader Storeでは未来に公開する記事を、プレビューの日時を指定し、その特定日時に想定した記事で公開されていることを確認したい、という要望がありました。ECサイトであるReader Storeにとって、未公開情報や商品の販売日などを考慮した日時指定プレビュー機能は重要なニーズです。microCMSには標準で下書きプレビュー機能がありますが、この要望を実現するには不十分でした。そのため、CMS側ではなくアプリケーション側の実装で対応することになりました。
このように、標準で提供されている機能から外れると何かしらの考慮が必要になってきますが、microCMSの標準として提供されている機能の範囲であれば、容易に利用ができています。プレビュー機能も標準的な導入は、各フレームワークごとにドキュメントが整理されており容易でした。

内部で管理するインフラを縮小

SaaS型のheadlessCMSを利用することで、インフラ側のOS、利用モジュールのEOLやセキュリティ対応がCMSサービス提供側の責務になります。これにより、自社でメンテナンス対象とするインフラの範囲を現在よりも縮小できました。
今回利用したmicroCMSは開発用ステージング環境を作成する機能を提供していました。本番環境をベースに迅速に別の環境を用意できます。各環境に対して接続IPなどを個別に設定できるため、本番用と開発用を簡単に作成できました。
しかし、サービス提供型のCMSであるため、提供側のインフラに関する制約も考慮する必要があります。例えば、秒間リクエスト数に制限があり、キャッシュからのリクエストには制限がないといった点です。この制限により、クエリに日時を指定した場合にmicroCMSのCDN(CloudFront)のキャッシュがヒットしにくいという事象が発生しました。キャッシュのヒット率を高めるために、指定する日時の秒・ミリ秒単位を切り捨てるといった実装上の工夫をしました。

管理画面上の細かいカスタマイズはフルカスタムに比べ劣る

提供されている機能で実現をする必要があったので、制限されることや別の箇所で回避しなければいけないことがありました。運用部門に強いこだわりがあり、管理画面のカスタマイズが必須要件の場合、フィットはしなかったです。 以下は一例です。

複数項目でのバリデーションができなかった

日時の開始日・終了日を設定するなど、複数項目間でのチェックをしたい場合、スクラッチであれば開始日<=終了日でなければ入力を弾くなどのバリデーションをかけるところでした。しかし、そういった機能の提供はなかったため、管理画面上に注釈を残して運用部門に注意を促し、フロントエンドの表示ロジックにて制御しました。

直接htmlを打ち込めない

テキストエリアにWYSIWYGエディターを提供されていたのですが、WYSIWYGエディター内でhtmlを直接打ち込む機能は提供されていませんでした。シンプルなページを作成する場合は問題ないのですが、特定のページをよりリッチに作成したい、といった要望が簡単には実現できないです。

さいごに

リニューアルに伴いSaaS型headlessCMSを使い始めることになりましたが、一般的に言われるメリット・デメリットは実感できました。それらを踏まえて、開発チームがフロント開発に注力できるチームであれば、CMSを導入する際の選択肢としてheadlessCMSは良い選択肢と言えます。本稿でそれらの感じたことをお伝えできていれば幸いです。
ここまで読んでいただきありがとうございました。

コミックROLLYアプリ速度改善のためにやったことについて

自己紹介

株式会社ブックリスタのプロダクト開発部でエンジニアをしている城と申します。 コミックアプリ「コミック ROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)」のアプリ開発を担当しています。


はじめに

コミックROLLYアプリはFlutterを用いて開発され、リリースから1周年を迎えました。 機能やUIの充実が進む一方で、「画面表示がもう少し早ければ...」という声が増えてきました。

幸いにも、エンジニア間でこの課題が議論されており、具体的な改善案があったため迅速に対応できました。 本記事では、コミックROLLYが実施したパフォーマンス改善の具体的な内容を共有します。


アプリの基本構成

コミックROLLYでは、以下の技術スタックを採用しています。

  • 状態管理: Riverpod
    UIとロジックの責務を分離し、再利用性と保守性を向上
  • 主な構成要素:
    1. Widget
      • 継承元: HookConsumerWidget
      • 画面を表すUIクラス
      • UIの状態管理はRiverpod経由で実現
    2. State
      • 画面の状態やデータを保持するクラス
    3. Notifier
      • 継承元: StateNotifier
      • ビジネスロジックを実装するクラス
    4. Provider
      • StateNotifierとUIを結びつける役割

パフォーマンス改善内容

State監視方法の見直し

WidgetRef.watch編

下記のStateからepisodesを監視して、値が変更されたらWidgetの再構成をトリガーしたい場合を例とします。

@freezed
class WorkPageState with _$WorkPageState {
  const factory WorkPageState({
    @Default([]) List<EpisodeEntity> episodes,
    @Default([]) List<RecommendEntity> recommends,
  }) = _WorkPageState;
}

改善前

final episodes = ref.watch(workPageProvider).episodes;

改善後

final episodes = ref.watch(workPageProvider.select((state) => state.episodes));

解説

改善前: WorkPageStateクラス自体を監視対象とし、そのメンバー変数episodesの参照を代入しているに過ぎません。そのためepisodes以外のrecommendsを変更した場合でもWidgetの再構成がトリガーされます。

改善後: 監視対象のepisodesを変更した場合だけWidgetの再構成がトリガーされます。


WidgetRef.listen編

StateにエラーメッセージerrorMessageが設定されたらダイアログでエラーメッセージを表示したい場合を例とします。

@freezed
class WorkPageState with _$WorkPageState {
  const factory WorkPageState({
    @Default("") String errorMessage,
  }) = _WorkPageState;
}

改善前

class WorkPage extends HookConsumerWidget {
  const WorkPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final errorMessage = ref.watch(workPageProvider.select((state) => state.errorMessage));

    // エラーメッセージがあればダイアログを表示する
    if (errorMessage.isNotEmpty) {
      showOkAlertDialog(context: context, message: errorMessage);
    }

    return Container(...);
  }
}

改善後

class WorkPage extends HookConsumerWidget {
  const WorkPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.listen(workPageProvider.select((state) => state.errorMessage), (prev, next) {
      if (next.isNotEmpty()) {
        showOkAlertDialog(context: context, message: next);
      }
    });

    return Container(...);
  }
}

解説

改善前: ダイアログを表示する目的でwatchを使用しています。 これでも一見期待通りの動作は得られますが、watchWidgetの再構成をトリガーするためのリスナーです。 ダイアログを表示したい事とWidgetの再構成は無関係なのにもかかわらずトリガーされ、ダイアログ表示を含むパフォーマンスに影響を及ぼします。

改善後: Widgetの再構成が必要ないStateの監視にlistenを使用しています。 これによりlistenの第二引数の関数が呼び出されWidgetの再構成はトリガーされません。


再構成の独立編

下記のStateからepisodesとrecommendsを監視して、それぞれ値が変更されたらWidgetの再構成をトリガーしたい場合を例とします。

@freezed
class WorkPageState with _$WorkPageState {
  const factory WorkPageState({
    @Default([]) List<EpisodeEntity> episodes,
    @Default([]) List<RecommendEntity> recommends,
  }) = _WorkPageState;
}

改善前

class WorkPage extends HookConsumerWidget {
  const WorkPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Column(
      children: [
        _buildEpisodes(ref),
        _buildRecommends(ref),
      ],
    );
  }

  Widget _buildEpisodes(WidgetRef ref) {
    final episodes = ref.watch(workPageProvider.select((state) => state.episodes));
    return Container(...);
  }

  Widget _buildRecommends(WidgetRef ref) {
    final recommends = ref.watch(workPageProvider.select((state) => state.recommends));
    return Container(...);
  }
}

改善後

class WorkPage extends HookConsumerWidget {
  const WorkPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Column(
      children: [
        _buildEpisodes(),
        _buildRecommends(),
      ],
    );
  }

  Widget _buildEpisodes() {
    return Consumer(builder: (context, ref, _) {
      // ScopeA
      final episodes = ref.watch(workPageProvider.select((state) => state.episodes));
      return Container(...);
    });
  }

  Widget _buildRecommends() {
    return Consumer(builder: (context, ref, _) {
      // ScopeB
      final recommends = ref.watch(workPageProvider.select((state) => state.recommends));
      return Container(...);
    });
  }
}

解説

改善前: episodesrecommendsがセットされた時、それぞれWidget全体が再構成されます。

改善後: episodesがセットされた時はScopeAのみ、recommendsがセットされた時はScopeBのみ再構成されます。 改善前はbuildWidgetRefwatchしていましたが、改善後はConsumerWidgetRefwatchしているのがミソです。 再構成が必要な箇所だけ独立できるので効率的になりました。


State設定の見直し

ここまでState監視時に気をつけたいポイントは記載しました。 次からはState設定時に気をつけたいポイントです。

下記のように画面の操作をビジネスロジック側でハンドリングさせたい場合を例にします。

class WorkPage extends HookConsumerWidget {
  const WorkPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final episodes = ref.watch(workPageProvider.select((state) => state.episodes));
    final ignorePointer = ref.watch(workPageProvider.select((state) => state.ignorePointer));
    return IgnorePointer(
      ignoring: ignorePointer,
      child: Container(...),
    );
  }
}

改善前

class WorkPageStateNotifier extends StateNotifier<WorkTopState> {
  WorkPageStateNotifier(super.state) {
    fetch();
  }
  Future<void> fetch() async {
    final episodesResponse = await episodesRepository.get();
    state = state.copyWith(episodes: episodesResponse);
    final ignorePointer = await checkResponse(episodesResponse);
    state = state.copyWith(ignorePointer: ignorePointer);
  }
}

改善後

class WorkPageStateNotifier extends StateNotifier<WorkTopState> {
  WorkPageStateNotifier(super.state) {
    fetch();
  }
  Future<void> fetch() async {
    final episodesResponse = await episodesRepository.get();
    final ignorePointer = await checkResponse(episodesResponse);
    state = state.copyWith(
      episodes: episodesResponse
      ignorePointer: ignorePointer,
    );
  }
}

解説

改善前: 分かれていたStateの変更を1つにまとめています。 State変更処理の間に非同期処理があるため、例で記載したウィジェットでは再構成が2度トリガーされてパフォーマンスが低下します。

改善後: 非同期処理の完了後にまとめてState設定することで再構成を最小限に抑えられました。 補足:State変更処理の間が同期処理のみであればウィジェットの再構成は1度に集約されますが、listenの場合はそれでも2度に分かれます。


ListViewの見直し

ListView.builderの改善

ListView.builderを使用すると必要な要素だけを動的に作成することでListViewを高速に表示できます。 しかしListView.builderを使用していても全要素を一度に生成してしまうケースがありました。

改善前

class WorkPage extends HookConsumerWidget {
  const WorkPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final episodes = ref.watch(workPageProvider.select((state) => state.episodes));
    return SingleChildScrollView(
      child: Column(
        children: [
          _buildSearchHeader(),
          ListView.builder(
            shrinkWrap: true,
            physics: const NeverScrollableScrollPhysics(),
            itemCount: episodes.length,
            itemBuilder: (context, index) {
              return _buildEpisode(episodes[index]);
            },
          ),
        ],
      ),
    );
  }
}

改善後

class WorkPage extends HookConsumerWidget {
  const WorkPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final episodes = ref.watch(workPageProvider.select((state) => state.episodes));
    return Column(
      children: [
        _buildSearchHeader(),
        Expanded(
          child: ListView.builder(
            shrinkWrap: true,
            itemCount: filteredItems.length,
            itemBuilder: (context, index) {
              return _buildEpisode(episodes[index]);
            },
          ),
        ),
      ],
    );
  }
}

解説

改善前: SingleChildScrollViewがスクロール可能なビュー全体をメモリ上に展開しようとするためListView.builderのすべての要素分builderが走ります。 要素数が多くなるほど画面表示まで時間がかかることになりListView.builderの恩恵を受けられませんでした。

改善前: 必要な要素だけを動的に作成するようになりました。 今回の対応では_buildSearchHeaderをスクロールに含めないようにUI変更を伴いました。 ListViewのスクロール時にヘッダーもスクロールさせたい場合はCustomScrollViewを使用することで対応可能です。

itemExtentの勧め

結論から言うとListViewの要素の高さが一定である場合itemExitenを指定するとパフォーマンスが向上します。

ListView.builder(
  itemExtent: 100, // 各アイテムの高さを固定
  itemCount: 2000,
  itemBuilder: (context, index) {
    return ListTile(title: Text('Item $index'));
  },
);

解説

デバイスのディスプレイをスクロールした時、どれだけ画面をスクロールするかはスクロールの速さや距離が影響します。シークバーなら選んだ位置によってスクロール先が決まります。 このような方法でListViewを下方向に1000pxスクロールさせる必要があると仮定しましょう。 ListViewを1000px移動した先に表示するアイテムのindexはいくつでしょう。 それを求めるには順番にListViewのアイテムを生成して高さを求めないと答えが出なさそうです。 ではitemExtentであらかじめすべてのアイテムは100pxと決められていたらどうでしょう。 アイテムを生成せずとも即座に答えが出せます。パフォーマンスとメモリ使用量の削減ができました。


最後に

今回の記事ではレイアウトに関する内容をメインで紹介させていただきました。 コミックROLLYでは今回の記事で紹介した改善することで、チカチカして見えたのが解消する、画面起動からコンテンツが表示されるまでの時間を25%前後も短縮するなどの効果がありました。

紹介した内容の他に非同期処理の並列化など、現在もパフォーマンス改善に取り組んでいます。 またナレッジが溜まったらご紹介させていただきたいと思っていますし、より良いアプローチや別のナレッジがあればぜひ教えて頂きたいです。

この記事の内容が、アプリ開発をされている方の何かしらのヒントになれば幸いです。

GitHubで静的コンテンツを公開する仕組みを手軽く構築した話

はじめに

こんにちは。私は株式会社ブックリスタのプロダクト開発部の姚です。
現在、コミックROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)のバックエンド開発を担当しています。
この記事では、運用チームが管理する静的なLPページ、バナー画像などコンテンツを公開する仕組みの構築方法を紹介します。

背景

運用チームの役割

運用チームは、LPページやバナー画像などの静的コンテンツの公開や更新を担当しています。
これらのコンテンツを管理し、適切なタイミングでエンドユーザーに提供することが求められています。

従来の業務フロー

LPページやバナー画像などのコンテンツは、Amazon S3に保存され、CloudFront経由で公開されていました。
運用チームはS3にアクセスする権限を持っておらず、コンテンツのアップロードや変更は開発チームに依頼する必要がありました。

この業務フローを図示すると以下のようになります。

旧業務フロー

  1. 依頼
    運用チームは、コンテンツのアップロードや変更を開発チームに依頼します。

  2. アップロード(検証環境)
    開発チームは依頼を受け、まず検証環境のAmazon S3にコンテンツをアップロードします。ここで、コンテンツの表示が正しいかどうかを確認します。

  3. 動作確認
    運用チームと開発チームが協力して、検証環境でアップロードしたコンテンツの動作確認を行います。

  4. アップロード(本番環境)
    検証環境で問題がないことを確認後、開発チームが本番環境のAmazon S3にコンテンツをアップロードします。コンテンツはAmazon CloudFrontを通じてエンドユーザーに提供されます。

課題

サービス開始当初はこの業務フローでも問題はありませんでした。しかし、事業が成長し、キャンペーンや企画の増加に伴い、コンテンツの更新頻度が大幅に増えました。その結果、次のような課題が浮上し、迅速な対応が難しくなりました。

  1. 対応時間にばらつきがある
    開発チームのスケジュールに依存するため、依頼から公開までの対応時間にばらつきが生じてしまいます。

  2. 手動による多重作業
    検証環境と本番環境の両方に手動でアップロードする必要があり、作業が煩雑になりミスのリスクも高まります。

  3. 更新頻度の増加による負荷
    コンテンツの更新頻度が増加することで、運用チームからの依頼が増え、コミュニケーションコストと開発チームにかかる負荷も高まります。

  4. ゴミファイルが残りやすい
    フォルダーの置き換えによるアップロード作業では、削除や差し替えの際に不要なファイルが残りやすく、管理が煩雑になります。

対策案の選定

複数の対策を検討し、課題解決と開発コストを考慮した結果、最終的にGitHubを使う案を採用しました。

コンテンツ管理画面を作る

メリット

  • 運用者が直感的に操作できる

デメリット

  • 環境間の同期で課題がある

    • 運用者に手動でアップロードしてもらうと、従来の環境間同期ミスが発生しやすい課題は解決しない
    • システムが検証環境から本番環境へ同期の仕組みを作ると、開発コストがかかるし、システム構成も複雑になる
  • 単純なアップロードは容易ですが、削除や差し替えも考慮すると複雑になる

FTPでS3に接続する

メリット

  • 運用側には慣れた手法でアップロードできる

デメリット

  • 環境間の同期でミスが発生しやすい
  • セキュリティを確保するためには、適切なプロトコルを選択し、設定を正しく行う必要がある
  • FTPサーバーのコストがかかる

GitHubでファイルを管理してS3に自動デプロイする

メリット

  • ファイル管理がしやすい

    • GitHub Desktopを使えば、リポジトリでファイルの追加・削除や差し替えが直感的に管理できる
    • GitHubのアカウントでログインするだけで利用でき、SSHなどの設定が要らない
    • GUIでファイルの操作ができるため、Gitに不慣れな運用者でも扱いやすい
  • 開発コストを削減できる

    • GitHubの機能を活用することで、ファイルアップロードや権限管理などの追加開発コストを削減できる
    • プルリクエストやレビュー機能を活用することで、承認フローを簡単に導入できる
  • 検証環境から本番環境への同期が容易

    • GitHubのブランチを使って、検証環境と本番環境の同期を自動化できる
    • 手動でのファイルアップロードミスや、本番環境と検証環境の差異を防ぐことができる

デメリット

  • 運用者がGitを操作する必要がある
    • Gitの基本操作やGitHubの使い方に慣れていない運用者には、操作方法を学ぶ必要がある
    • 運用者がスムーズに利用できるため、適切な手順書やレクチャーの準備とサポートが必要になる

実現

GitHub リポジトリの用意

プロジェクト用の新しいGitHubリポジトリを作成します。
GitHub Teamを使用して、運用チームメンバーのアカウントをチームに作成し、適切なアクセス権限をリポジトリに付与します。

検証環境と本番環境の管理を容易にするため、適切なブランチとディレクトリ構成を準備します。
以下は一例です。

ブランチ

検証環境: staging
本番環境: main

ディレクトリ

readme.md(説明や運用手順など書く)
resource-folder/(資材を置くフォルダー)

CodePipeline の設定

GitHubのリポジトリに変更があった場合、S3への自動デプロイの手法の選択肢がいくつかありますが、今回はCodePipelineを使用方法を紹介します。

CodeBuildポリシーの作成

S3へのアクセス権限を付与するため、適切なIAMポリシーを作成します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:Get*",
                "s3:List*",
                "s3:Put*",
                "s3:Delete*"
            ],
            "Resource": [
                "arn:aws:s3:::bucket-name",
                "arn:aws:s3:::bucket-name/*"
            ],
            "Effect": "Allow"
        },
    ]
}

CodePipelineのビルドステージを設定

  1. ソースのダウンロード

GitHubSourceActionを利用して、GitHubからソースコードをダウンロードします。
OauthTokenを用いて、GitHubのリポジトリにアクセスできるように設定します。

  1. S3にファイルをアップロードする

AWS CLIを使用して、ダウンロードしたファイルをS3に同期します。とくに、GitHubリポジトリから削除されたファイルもS3から削除するために、aws s3 syncコマンドを使用します。

aws s3 sync resource-folder s3://bucket-name/target-folder --delete

このようにして、GitHubのリポジトリに変更があるたびに、S3に自動で変更内容が反映されるようにします。

変更後の業務フロー

業務フローは以下のように変更され、運用者がGitHubを使ってコンテンツを管理できるようになります。

新業務フロー

使用後の評価と課題

実際に使った体験

使いやすさ

GitHub Desktop経由でGUI操作が多く、ファイルやフォルダーを直感的に管理できるため、運用者にとって使いやすかったです。

手間の削減

自動デプロイの導入により、開発チームへの依頼が不要になり、運用チームと開発チームの間でのやりとりや反映までのスケジューリングが不要となりました。これにより、作業の手間が大幅に削減されました。

反映の速さ

GitHubにコミットした変更がすぐに反映されるため、作業のタイムラグがほとんどなくなりました。また、検証環境と本番環境の同期も自動化されたため、ミスが発生しにくくなりました。

サポート体制

導入時に提供されたドキュメントが手厚く、レクチャーも充実していたため、スムーズに利用を開始できました。
GitHub運用初心者も、ドキュメントがわかりやすく、迷うことなく作業を進められました。また、導入ドキュメントがしっかりしていたため、運用者間の引き継ぎも想定より早く終わりました。

さらに、レクチャー後も困ったことがあればすぐに質問・相談できる環境が整っており、そのサポート体制は非常にありがたかったです。これにより、チーム全体が新しいシステムに迅速に適応し、効率的に運用できています。

残る課題

途中の作業の検証

作成中のコンテンツを検証環境にアップロードして検証している最中に、本番環境への緊急リリースが必要になる場合、検証中のコンテンツが本番環境に反映されるリスクがあります。

対策として、本番環境と同期しない別の検証環境を用意することが考えられます。

複数人での作業

通常のGit-flowは運用者にとって難しいため、stagingブランチへの直接アップロードを採用しました。しかし、複数人が同時に作業する場合には、ファイルの競合の可能性があります。

対策として、運用者には、同じファイルを同時に編集しないように注意してもらうこと、複数人で同時に作業する場合にはお互いに連携を取ることを促す。また、操作手順のレクチャーして、スムーズな運用をサポートします。

まとめ

GitHubを使用することで、ファイルの管理や権限設定がしやすくなり、検証環境から本番環境への反映も自動化できます。ただし、運用者がGitを操作する必要があるため、適切な手順書の作成とレクチャーが求められます。

このように、GitHubの機能を活用することで、効率的なコンテンツ管理が可能になります。