UPSIDER Tech Blog

Go Conference 2024 にBronze スポンサーとして協賛 & slogについて登壇しました! #gocon

UPSIDERでエンジニアリングマネージャーをしているMiki (@m_miki0108) です!

2024年6月8日(土)に開催されたGo Conference 2024に登壇し、Bronzeスポンサーで協賛いたしました!

Go Conferenceとは?

Go Conferenceとは、年に一度開催されるGo言語に関するカンファレンスです。

今年は数年ぶりのオフライン開催で、キャパシティ400名のところキャンセル待ちが数百人という大人気かつ大規模なイベントになりました。 当日はセッションはもちろん、オフラインならではの交流やイベントブースなど盛り沢山な内容でした!

登壇した内容

「Custom logging with slog Making Logging Fun Again!」という内容で20分の英語セッションをいたしました。こちらがスライドです。

内容は、「Goの1.21から標準ライブラリとして導入されたslogのCustom Handlerを作ってログ出力をカスタムしてみよう!」というAll Levelのセッションです。 slogを触ったことのない人も楽しめるように以下のような内容でお話ししました。

  • slogの基本
  • slogのアーキテクチャ
  • シンプルなslogのCustom Handlerを作るデモ
  • Performance関連の考慮事項

デモに関して、Custom Handlerを作るライブコーディングをやりたかったのですが今回は20分のセッションということで時間的になかなか厳しそうでした。

なので実装をステップに分けてGitの差分を見てもらいながら理解してもらう方針でやってみました。結果として「初見の人にもわかりやすい内容になったかな?」と思っています。

また、今回のGo Conferenceは株式会社サイバーエージェントさんのAbema Towersを会場に使わせていただいたということで、サンプルコードにこそっとAbema.tvを忍び込ませてみました。

こういう遊び心を忘れないように日々仕事しています!

UPSIDERでの取り組み

当社ではGoで書かれたマイクロサービスがたくさん存在しています。

会社全体のGoのプロジェクトで共通的に使っているものも多いですが、各チームで持っているサービスの特性や非機能要件、作られた時期や開発者の方針によってはアーキテクチャや利用しているライブラリが違うこともよくあります。 ロギングについても同様で、現状slogを使っているプロジェクトもあれば、standard libraryのlogをカスタムして使っていたり、サードパーティのzapを使っているところもあります。

個人的にはGoのプロジェクト全体で統一・標準化して開発スピードを落とさずに、より調査しやすく必要十分なロギングが実現できるようにしたいと思っています。

slogの良いところは、標準ライブラリでありlogとの後方互換性も保たれていること、サードパーティのロギングpackageでも共通の処理(バックエンド)を利用することができることです。(セッションでお話ししたように、APIをログメッセージの必要な情報を収集するフロントエンドとそれらを処理するバックエンドに分けるような構成になっています。)

これらのメリットを踏まえつつ、全体でどういう形にするのが最適なのかはこれから模索していきたいところです!

We are hiring!

UPSIDERではGoのエンジニアの採用中です! より具体的な業務事例をNoteで発信しています。ぜひご覧ください! note.com

ご興味をもっていただいた方はぜひカジュアルにお話ししましょう! pitta.me herp.careers

UPSIDERのこれからを担うFlutterアプリのアーキテクチャ

こんにちは、UPSIDERで日々モバイルアプリ開発をしているふっくです。

UPSIDERでは今後、よりアプリ開発に注力し決済プラットフォームの中核的な役割を果たすことを目指しています。

今回は、今後の開発・運用を目指して考えたFlutterアプリ向けのアーキテクチャを紹介します。

ネイティブアプリの世界で触れてきた色々なアーキテクチャフレームワークを参考に、開発の後半でも順調にスケールさせることができるように、工夫を凝らしました。

アーキテクチャで作ったサンプルアプリもあるので、ぜひ以下のリンクから見てみてください。

https://github.com/upsidr/flutter_architecture_blueprint

デモはこちら

https://upsidr.github.io/flutter_architecture_blueprint/

対象読者

この記事は、Flutterアプリの設計に興味がある方を対象としています。また、以下の知識を持っている前提となっています。

目指すところ

サービスの成長に応じてアプリを破綻なくスケールさせるためには、適切なレイヤー分割とその疎結合化が必要になります。

アーキテクチャは、

  • Presentation / Domain / Data の3層レイヤーを採用する
  • View と ViewModel (このブログでは以降Notifierと呼びます) の責務を明確に分離する
  • Notifierの内部では、StateとActionなどを用いて整理し、アプリの振る舞いを予測可能なものにする

の3つを前提に設計を行いました。

持続可能な形で安全にアプリ開発を進めることができるよう、テストコードフレンドリーになるよう設計しました。とくに、Presentation層のテストがしやすい形を目指しました。

参考にしたもの

全体的な作りは、モバイルアプリで流行りの実装手法を参考にしました。

Now in Android

Now in Androidは、Google Androidチームによる開発者向けの技術記事のシリーズ名です。同タイトルで、Android開発におけるベストプラクティスを示すリファレンスアプリが公開されています。

https://developer.android.com/series/now-in-android?hl=ja

関心事で分離するモジュール

関心事で分離するモジュール構成は広く知られています。Flutterにおいてもディレクトリの切り方として扱いやすいため、参考としました。

https://github.com/android/nowinandroid/blob/main/docs/ModularizationLearningJourney.md

モックライブラリに深く依存しないテストダブル

DIを活用してテストダブルにFakeを使用することで、モックライブラリに深い依存をせずにテストコードを記述することができます。これにより、テストコードがシンプルになり、忠実度の制御が容易になります。

https://github.com/android/nowinandroid?tab=readme-ov-file#testing

    class TestUserDataRepository : UserDataRepository {
        /**
         * The backing hot flow for the list of followed topic ids for testing.
         */
        private val _userData = MutableSharedFlow<UserData>(replay = 1, onBufferOverflow = DROP_OLDEST)
     ...
         /**
         * A test-only API to allow setting of user data directly.
         */
        fun setUserData(userData: UserData) {
            _userData.tryEmit(userData)
        }
    }

The Composable Architecture

The Composable Architecture (TCA)は、Point-Freeチームが開発しているiOS向けのアーキテクチャフレームワークです。

https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/

UIの抽象化

TCA はUIを State / Action で抽象化しています。他の宣言型UIの世界でも見られる手法であり、単方向データフローを構築しやすい・テスト容易性が向上するなどのメリットがあります。

@Reducer
struct Feature {
  struct State { /* ... */ }
  enum Action { /* ... */ }
  var body: some ReducerOf<Self> {
    // ...
  }
}

swift-dependencies

TCA では swift-dependencies というDIライブラリを使っています。このライブラリでは、メソッドをクロージャープロパティで定義し、本番用・テスト用などと実装を差し替え可能にする手法が紹介されています。

https://swiftpackageindex.com/pointfreeco/swift-dependencies/main/documentation/dependencies/designingdependencies#Struct-based-dependencies

struct AudioPlayerClient {
  var loop: (_ url: URL) async throws -> Void
  var play: (_ url: URL) async throws -> Void
  var setVolume: (_ volume: Float) async -> Void
  var stop: () async -> Void
}

ライブラリ選定

アーキテクチャに関わるパッケージを以下のように選定しました。

目的 パッケージ
状態管理 hooks_riverpod, rxdart
モデル freezed
ルーティング auto_route

状態管理

3層レイヤーを採用するため、レイヤー間の繋ぎ込み(DI)に riverpod を使いました。

rxdartは、主にBehaviorSubjectを使ってData層から Presentation層 までのデータの伝搬に使用します。

モデル

Dart3では、代数的データ型(直和型・直積型)を言語レベルで扱えるようになりました。

https://dart.dev/language/patterns#algebraic-data-types

これに freezed を組み合わせると、より簡単に記述することができるようになるので採用しました。

https://pub.dev/packages/freezed#legacy-union-types-and-sealed-classes

ルーティング

go_router / auto_route から、書き心地の好みで auto_route を採用しました。

アーキテクチャ解説

アーキテクチャにおいて特徴的なPresentation層に焦点を絞って解説をします。

Domain・Data層の詳細はサンプルコードをご参照ください。

https://github.com/upsidr/flutter_architecture_blueprint

Presentation層

アーキテクチャのデータフローは以下のようになります。

アーキテクチャーのデータフロー

図のように、UIState / Action / Effect を使って View・Notifier間の単方向データフローを実現します。

■ Contract

ここでは、TCAのUI抽象化 を参考に画面が取りうる全ての状態を抽象化して記述し、クラスとして定義します。

  • UIState - 画面の表示要素
  • Action - UIの操作イベント
  • Effect - 画面遷移などの画面イベント

この3要素をまとめて Contract という名前で扱います。

TCAは State と Action の2つを定義しますが、本アーキテクチャでは State を UIState と Effect に分割しています。

これは、SwiftUIがダイアログ表示や画面遷移を1つの State として扱えるのに対し、FlutterではUIの宣言とは別に手続き的に表示・遷移処理が必要であり、Effect が独立している方が扱いやすいためです。

サンプルのタスク一覧画面の Contract がこちら

abstract class BaseContract<UiState, Action, Effect> {
  void consume();
  Future<void> send(Action action);
}

typedef TaskListContract
    = BaseContract<TaskListUiState, TaskListAction, TaskListEffect>;

// 画面の表示要素
@freezed
class TaskListUiState with _$TaskListUiState {
  const factory TaskListUiState({
    @Default([]) List<EditableUserTask> taskList,
  }) = _TaskListUiState;
}
//  UIの操作を定義
@freezed
sealed class TaskListAction with _$TaskListAction {
  const factory TaskListAction.onAppear() = OnAppear;
  const factory TaskListAction.newTaskButtonTapped() = NewTaskButtonTapped;
  const factory TaskListAction.taskTapped(EditableUserTask task) = TaskTapped;
  const factory TaskListAction.toggleIsCompleted(EditableUserTask task) =
      ToggleIsCompleted;
  const factory TaskListAction.onTaskSwiped(EditableUserTask task) =
      OnTaskSwiped;
}
// 画面遷移などの画面イベントを定義
@freezed
sealed class TaskListEffect with _$TaskListEffect {
  const factory TaskListEffect.none() = None;
  const factory TaskListEffect.goDetail({
    required EditableUserTask? task,
  }) = GoDetail;
  const factory TaskListEffect.showAlert({
    required AlertState state,
  }) = ShowAlert;
}

■ Notifier

ここでは

  • UIState・Effect の管理
  • Action のハンドリング

を行います。

Notifier は AutoDisposeNotifierProvider で実装し、UIState を state として扱います。

Effect は別途 StateProvider を作って管理します。

サンプルのタスク一覧画面の Notifier はこちら

(riverpod_generator による自動生成)

final taskListEffectProvider =
    StateProvider((ref) => const TaskListEffect.none());

@riverpod
class TaskListNotifier extends _$TaskListNotifier implements TaskListContract {
  late final StreamSubscription _taskListSubscription;
...
  @override
  TaskListUiState build() {
    _taskListSubscription = _taskListUseCase()
        .listen((list) => state = state.copyWith(taskList: list));
    ref.onDispose(_taskListSubscription.cancel);
    return const TaskListUiState();
  }

  @override
  void consume() {
    _updateEffect(const TaskListEffect.none());
  }

  @override
  Future<void> send(TaskListAction action) async {
    switch (action) {
        case NewTaskButtonTapped():
        _updateEffect(const TaskListEffect.goDetail(task: null));
            case ...:
              state = state.copyWith(...)
    }
  }

  _updateEffect(TaskListEffect effect) =>
      ref.read(taskListEffectProvider.notifier).update((state) => effect);
}

■ View

ここでは

  • UIState の表示
  • Effect のハンドリング
  • Action の送信

を行います。

Extensionプロパティを定義して、 ref.notifier.send() の形で Action を送信できるようにしています。

Effect は ref.listen で購読し、画面イベントを副作用として処理します。

サンプルのタスク一覧画面の View はこちら

extension _TaskListEx on WidgetRef {
  TaskListNotifier get notifier => read(taskListNotifierProvider.notifier);
}

@RoutePage()
class TaskListPage extends HookConsumerWidget with AlertStateCompatible {
  const TaskListPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.listen(taskListEffectProvider,
        (_, effect) => handleEffect(context, ref, effect: effect));
    ...
    return Scaffold(
      appBar: AppBar(
        title: const Text('ToDo'),
        centerTitle: true,
      ),
      body: const _TaskListBody(),
      floatingActionButton: FloatingActionButton(
        onPressed: () =>
            ref.notifier.send(const TaskListAction.newTaskButtonTapped()),
        child: const Icon(Icons.add),
      ),
    );
  }

  Future<void> handleEffect(
    BuildContext context,
    WidgetRef ref, {
    required TaskListEffect effect,
  }) async {
    switch (effect) {
      case None():
        break;
      case GoDetail():
        ref.notifier.consume();
        context.router.push(EditTaskRoute(task: effect.task));
      case ShowAlert():
        ref.notifier.consume();
        handleAlertState(context, effect.state);
    }
  }
}

class _TaskListBody extends HookConsumerWidget {
  const _TaskListBody();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final taskListCompleted = ref.watch(
      taskListNotifierProvider.select(
          (value) => value.taskList.where((t) => t.isCompleted)),
    );
    ...

    return CustomScrollView(
      slivers: [
        ...
        SliverPadding(
          padding: const EdgeInsets.symmetric(vertical: 16),
          sliver: SliverList(
            delegate: SliverChildListDelegate(
                buildList(ref, taskList: taskListCompleted)),
          ),
        ),
      ],
    );
  }
...
}

ディレクトリ構成

Now in Andoirdをベースとしています。

ディレクトリ構成

├── app_router.dart
├── app_router.gr.dart
├── core
│   ├── data
│   │   ├── network
│   │   │   └── fake_todo_api_client.dart 👈 サンプルでは実際の通信はナシ
│   │   └── repository
│   │       └── todo
│   │           ├── fake_todo_repository.dart
│   │           ├── fake_todo_repository.freezed.dart
│   │           ├── todo_repository.dart
│   │           └── todo_repository.freezed.dart
│   ├── domain
│   │   ├── model
│   │   │   ├── editable_user_task.dart
│   │   │   └── editable_user_task.freezed.dart
│   │   └── todo
│   │       ├── edit_task_usecase.dart
│   │       ├── edit_task_usecase.freezed.dart
│   │       ├── task_list_usecase.dart
│   │       └── task_list_usecase.freezed.dart
│   ├── model
│   │   ├── user_task.dart
│   │   ├── user_task.freezed.dart
│   │   └── user_task.g.dart
│   └── util
│       ├── alert_state.dart
│       ├── alert_state.freezed.dart
│       ├── base_contract.dart
│       ├── datetime_formatted.dart
│       └── stream_extensions.dart
├── feature
│   └── todo
│       ├── edit_task
│       │   ├── edit_task_contract.dart
│       │   ├── edit_task_contract.freezed.dart
│       │   ├── edit_task_notifier.dart
│       │   ├── edit_task_notifier.g.dart
│       │   ├── edit_task_page.dart
│       │   └── ui_components
│       │       ├── task_text_field.dart
│       │       └── toggle_complete_button.dart
│       └── task_list
│           ├── task_list_contract.dart
│           ├── task_list_contract.freezed.dart
│           ├── task_list_notifier.dart
│           ├── task_list_notifier.g.dart
│           ├── task_list_page.dart
│           └── ui_components
│               └── task_list_item.dart
│               └── task_list_placeholder.dart
├── main.dart
└── main_device_preview.dart

feature 直下のディレクトリは関心事の単位で切ります。タスク一覧画面のファイルの場合は以下の箇所になります。

👉 lib/feature/todo/task_list

── task_list
   ├── task_list_contract.dart
   ├── task_list_contract.freezed.dart
   ├── task_list_notifier.dart
   ├── task_list_notifier.g.dart
   ├── task_list_page.dart
   └── ui_components
       ├── task_list_item.dart
       └── task_list_placeholder.dart

テストについて

アーキテクチャは、Presentation層のテストに重きを置いています。

画面の実装では、Contract のモデリングを真っ先に行うことで Notifier をテスト駆動開発(TDD)で実装することが可能になります。

基本的に Notifier のユニットテストは以下の流れで実装します。

  1. notifier.sendメソッドで Action を送信
  2. Notifierが処理した結果の UIState / Effect を評価する

サンプルのタスク一覧画面の Notifier 「タスク作成ボタンをタップしてタスク編集画面に遷移する」のテストがこちら

group('Navigation', () {
  test('Tap NewTaskButton, navigate detail', () async {
    final (notifier, uiState, effect) = buildAccessors();
    
    // 👇 Data層のFakeRepositoryの挙動を変更
    todoRepository.handler.fetchTaskList = () async =>
        fakeTodoState.update((value) => value.copyWith(taskList: []));

    await notifier.send(const TaskListAction.onAppear());
    expect(uiState().taskList.isEmpty, true);

    await notifier.send(const TaskListAction.newTaskButtonTapped());
    expect(
      effect(),
      const TaskListEffect.goDetail(task: null),
    );
  });
  ...

setUp にてFakeをDIしています。

void main() {
  ...
  buildAccessors() {
    final subscription = container.listen(taskListNotifierProvider, (_, __) {});
    addTearDown(subscription.close);
    return (
      container.read(taskListNotifierProvider.notifier),
      () => container.read(taskListNotifierProvider),
      () => container.read(taskListEffectProvider),
    );
  }
  ...
  setUp(() {
    fakeTodoState = BehaviorSubject.seeded(const FakeTodoRepositoryState());
    todoRepository = FakeTodoRepository.from(fakeTodoState);
    container = ProviderContainer(overrides: [
      todoRepositoryProvider.overrideWithValue(todoRepository), // 👈 ココでDI
    ]);
    ...
  });

  tearDown(() => container.dispose());
...

FakeTodoRepositoryの実装

swift-dependencies DIのアイデアを使っています 👍

  class FakeTodoRepository implements TodoRepository {
    const FakeTodoRepository._(this.fakeState, this.handler);

    factory FakeTodoRepository.from(
        BehaviorSubject<FakeTodoRepositoryState> fakeState) {
      return FakeTodoRepository._(
          fakeState, FakeTodoRepositoryHandler(fakeState));
    }

    final BehaviorSubject<FakeTodoRepositoryState> fakeState;
    final FakeTodoRepositoryHandler handler;

    @override
    Stream<List<UserTask>> get taskList =>
        fakeState.map((state) => state.taskList);

    @override
    Future<void> fetchTaskList() async {
      await handler.fetchTaskList();
    }

    @override
    Future<void> addTask({required UserTask task}) async {
      await handler.addTask(task);
    }

    @override
    Future<void> updateTask({required UserTask task}) async {
      await handler.updateTask(task);
    }

    @override
    Future<void> removeTask({required String id}) async {
      await handler.removeTask(id);
    }
  }
    
  class FakeTodoRepositoryHandler {
    FakeTodoRepositoryHandler(this.fakeState);
    
    final BehaviorSubject<FakeTodoRepositoryState> fakeState;
    
    late AsyncCallback fetchTaskList = () async {};
    late AsyncValueSetter<UserTask> addTask = (task) async {
      fakeState.update(
          (value) => value.copyWith(taskList: value.taskList.appending(task)));
    };
    late AsyncValueSetter<UserTask> updateTask = (task) async {
      fakeState.update(
          (value) => value.copyWith(taskList: value.taskList.replaced(task)));
    };
    late AsyncValueSetter<String> removeTask = (taskId) async {
      fakeState.update(
          (value) => value.copyWith(taskList: value.taskList.deletedBy(taskId)));
    };
  }
    
  @freezed
  class FakeTodoRepositoryState with _$FakeTodoRepositoryState {
    const factory FakeTodoRepositoryState({
      @Default([]) List<UserTask> taskList,
    }) = _FakeTodoRepositoryState;
  }

今後に向けて

Flutter Web + device_preview の体験

今回のサンプルは device_preview を使って Flutter Web で公開してみました。

👉 https://upsidr.github.io/flutter_architecture_blueprint/

https://pub.dev/packages/device_preview

device_preview を Flutter Web と組み合わせることで、

  • アプリ外観の確認が容易になる
  • 誰でもすぐに成果物を閲覧できる

などの利点を強く感じました。

公開範囲の制限など考えないといけないこともありますが、開発フローの要所要所で活かしていきたいと思いました 👍

実装方針を決めてみた感想

プロダクトで実際に運用することになれば、色々な課題が出てくるでしょう。

ボイラープレートの自動生成をしてみる、状態の変更に対する網羅的なテストを実装できるようにするなど、チームのみんなと一緒に改良を続けていければと思います。

最後に

今回は運用検討中のFlutterアーキテクチャの紹介でした。

UPSIDERでは、「挑戦者を支える世界的な金融プラットフォームを創る」をミッションに世界中の挑戦者を支えるチャレンジをしています。UPSIDERが提供する法人カード「UPSIDER」のアプリは多くのユーザーにご活用いただいており、しつ高い顧客体験を提供すべき、日々開発に奮闘しております。より利便性の高いアプリ開発を通じて、ユーザー体験を向上させ、一緒に挑戦者を支えるチャレンジをしませんか?

ご興味を持っていただけたら、ぜひカジュアル面談でお話ししましょう!

herp.careers

余談

サンプルアプリを公開するにあたって、リポジトリ名をどうするかで少し議論になりました。

flutter_architecture_blueprintflutter-architecture-blurprint

結論、チームによっていろんな判断がありそうでした。

UPSIDERの決済領域における技術選定と、運用後の感想

はじめに

弊社は社名と同じ、法人カード「UPSIDER」と請求書カード払いサービス「支払い.com」等のサービスを提供し、「挑戦者を支える世界的な金融プラットフォームを創る」をミッションに掲げる会社です。 今回は、その根幹である決済領域のテックスタックを紹介しようと思います。

決済領域とはなんやねんという方はこちらの資料にわかりやすい説明が載っていますのでこちらもぜひチェケラしてください

https://www.slideshare.net/MikiMasumoto/go-upsider

ただ紹介しても面白味がないので、立ち上げフェーズにメインで進めていたRyoyaが導入時の経緯を、途中参画したTakuyaが参画後に使用してみた感想を、それぞれの視点で言い合う形式で進めていきます。

決済システムを取り巻くテックスタックとアーキテクチャ

各テックスタックの話に入る前に、決済領域のどこにどの技術やアーキテクチャを利用しているかを図とともに簡単に説明していこうと思います。 まず、決済システムを含めたUPSIDERのアプリケーションはほぼすべてKubernetesの上で動いています。決済システムは、そのインフラを活用して、多数のGoのマイクロサービスで構成されています。

そして、その一つ一つのGoのサービスのビルドとテストにはBazelを利用してきました。 また、メインのデータベースはSpannerとなっております。 それでは、各テックスタックを紹介していきます!

レッツ&ゴー

マイクロサービス

概要

僕らはProcessing領域のサービス群をマイクロサービスを使ってk8s上に構築しています。

また、それらサービス間の通信方法としてgRPCを採用しています。

導入時の経緯

決済のドメインにマイクロサービスを採用した理由として以下のようなものがありました。

  • 決済という複雑かつ多様なドメインの責務を分離しつつもメンテしやすくするため
    • 決済処理、カード発行、WebやアプリなどのUI向け機能などにわけることで、変更を加える際の影響範囲を限定的にする
    • すべてのメンバーが決済領域の関わる全機能や領域に精通していなくてもある程度の開発ができるようにする
  • 障害が起きた際の影響範囲を限定できる
    • 例えば、Webでの画面表示向けの機能で障害が起きても、決済処理には影響が出ないようにする
    • また、障害時の復旧作業の際に、作業が影響しうる他の機能を限定的にする

その一方で、マイクロサービスはドラスティックな変更には弱いというデメリットがあり初期フェーズでの採用はアンチパターンとされる場合も多いかと思います。

しかし、ユーザーが直接触る機能と違い、決済領域はカードの仕様や制約に縛られる部分が多いため、新規追加や中小規模の改変はあれど、既存のコアの機能にドラスティックな変更は入る頻度はあまり多くないことが予想されました。そこで、決済に関してはデメリットが大きくならないと判断して、導入を決断しました。

参画して使用してみた感想

参画当初は細かくサービスが分かれているので全体像の把握が困難でした。ただ、一つ一つはそこまで複雑なことはやっていないので割とすぐに慣れることができて、慣れたあとはメンテナンスが容易だと感じています。

また、gRPCを採用することによっていわゆるスキーマ駆動開発が強制されるため、開発順序の秩序が保たれるのもナイスです。

ただ、これはgRPC特有だと思いますが、Sliceのnilとemptyを区別することができないのは初心者に対する罠ポイントでした。(区別できる前提で設計をして実際に動かしてみたらどちらもnil扱いとなり、設計からやり直すというまぁまぁ大きな手戻りが発生してしまいました😭)

Go

概要

UPSIDERでは、大半のバックエンドの領域にGoを採用しています。決済領域もほぼすべてGoで書かれており、決済領域がUPSIDERのシステムの中で一番最初にGoで書かれた領域です。

導入時の経緯

初期のUPSIDERで言語を選定する中で、以下の基準で言語を選定した結果、決済領域にはGoを採用することになりました。

  • 型があること
  • 可読性が高いこと
  • テストが書きやすいこと
  • 並行処理が書きやすいこと

決済領域は、高い安定性とパフォーマンスが求められる一方で、複雑度・理不尽度の高いビジネスロジックを取り扱う上でメンテナンスしやすさも必要となります。そこで、上記のような強みを合わせ持つGoが最適な言語であると判断して導入を決めました。

参画して使用してみた感想

まずなんと言ってもマイクロサービス、gRPCとの圧倒的相性の良さが挙げられると思います。もともとGoは簡潔さを重んじるカルチャーの元作られている言語だと思っており、サービス単体の振る舞いがシンプルなこれらの技術と組み合わせることでGoならではのデメリットが打ち消されて、メリットが存分に発揮されていると感じます。

一例として、Goではいちいちエラーハンドリングを考慮しなければならないので、巨大なモジュールを作る場合などはerrorの扱いが複雑になりがちですが、マイクロサービスのような設計と併用することでデメリットが打ち消されているように思います。決済領域は例外ハンドリングを適切に行なった上で、異常系であってもその旨を伝えるレスポンスを返さないといけないのですが、Goのように常に例外を意識しながらコードを書いていくと、予想外のことが起こりづらいのです。

また、テストの実行がシンプルで素早いのはGoの明らかなメリットです。標準で提供されたテストの実行環境は他言語でテストフレームワークを使用した場合と比較して開発者体験が圧倒的に良いと感じています。決済領域のような、極端に品質を求められるシステムにおいて、テストコードの実装ハードルの低さはとても重要な要素です。

Google Cloud Spanner(Google Cloud)

概要

Google Cloud が提供するSpannerは、Google Cloud が提供するスケーラブルなフルマネージドデータベースです。大規模なトランザクション処理とSQLクエリのサポートが特徴です。

UPSIDERでは、どのデータストアを使うかサービス群の特徴によってそれぞれ判断おり、決済領域においてSpannerを採用しています。

導入時の経緯

決済領域のデータベースを選定する上で、まずドメインの性質から以下の基準を満たすデータベースを探しました。

  • メンテナンスコストや整合性を大きく犠牲にせず、スケーラビリティを担保できる
    • 決済領域はデータの取り扱い量が多いため
  • 高い可用性・安定性

上記二点だけであれば、Amazon DynamoDBなども選択肢に入りましたが、最終的に下記の二点から、Spannerの採用に至りました。

  • キャッチアップコストが低いこと
    • ANSI SQLをサポートしていて見かけはRDBに類似しているため、RDBの開発経験者にとってはNoSQL DBよりはるかに馴染みやすい。
  • インフラをGCP中心で構築していたこと

参画して使用してみた感想

私個人の話になりますが、まず参画時点ではSpannerの利用経験はなく、RDBは一通り使っていたものの、NoSQLはプロダクトで使ったことがないといったレベルでした。そのため、まずは概念の理解からスタートし、RDBとの考え方の差異を埋めていくところから始まりました。

とはいえ、そこまでハードルが高いものでもなく、また、SpannerはSQLによる問い合わせが可能なのも相まって、あまり苦戦はしなかった記憶があります。

ちゃんと運用し始めてからの感想としては、テーブル構成をしっかり考えておけば、ユーザー数がどんなに増えても安心なのは大変心強いと感じています。BtoBのサービスをホストしていると、しばしば障害が複数企業に影響してしまうなどのシチュエーションに遭遇しますが、ことデータストア周りにおいてはSpannerを利用することでそういったインシデントを未然に防ぐことができているように感じます。

一点苦言があるとすればGoogle Cloudのコンソールが少し使いづらいところです。クエリ結果をエクスポートする時とかなどはやむを得ずCLIを使っています。

Bazel

概要

Bazelは、Googleが開発したビルドおよびテストツールです。多言語サポートと実行環境の差異を排除したビルドを提供し、ビルドの再現性に優れているのが特徴です。

UPSIDERでは、決済領域のCI/CDにBazelを組み込み、ビルド・テストに利用しています。

導入時の経緯

Goの導入を決めた際には、ビルドの手段の準備が必要になってくるわけですが、その中でBazelを採用することになりました。

主な採用基準としては、以下のような点です。

  • 実行環境への依存が少なく、再現性を持ったビルドができる
  • 文法で比較的シンプルで書きやすい
  • 当時 Google が推しているプロジェクト(だと思っていた)ので、今後のメンテナンスや普及についても期待が持てた。

ただし、Bazelは最初のセットアップが非常に大変で、悪戦苦闘しながらなんとかセットアップして運用してきました。

参画して使用してみた感想

そもそもbazelファイルの書き方だとか、コンフィグレーションの方法を意識しなくてはならず、Go最大の特徴でありメリットであるシンプルさから逸脱してしまっているように感じてしまいました。

また、これは最適化を怠っているからだとは思うのですが、ビルドにめちゃくちゃ時間がかかるようになってしまったのは残念ポイントです。そもそも純正のGoのビルドにそこまで時間がかかっている印象はなく、逐次最適化していかないと本来のビルドよりも遅くなってしまう状態はナンセンスだと感じています。

そのため、途中からBazelによるビルドはやめ、今はGO_PRIVATEを設定して依存関係を成り立たせつつ、go buildを使ってデプロイ用のイメージを作成しています。

現在もBazelでビルドすることが前提になっているコードは残っていますが、それらもリプレイスしていく予定です。

Goのパッケージ: Testing(gomock & cmp)

概要

これまで紹介したセクションの中でも度々テストについては言及があったかと思いますが、僕らはテストに心臓を捧げた民族です。そのため、僕らのテストを支えてくれる技術に関して最後に紹介させてください。

導入時の経緯

  • gomock

    最初は自前でinterfaceを実装したモックのコードを一つ一つ書いてきました。ただ、さすがにそのコードを書いてメンテすることに疲弊していきました 😥その中で、途中からgomockを利用することになりました。

  • cmp

    Goの標準のテストパッケージでは、テスト結果と期待値を比較して差分をわかりやすく表示してくれるものはありません。最初はreflectパッケージを利用して、比較を行っていましたが、差分が生じたときの確認が一々手間になっていました。そこで、go-cmpパッケージを利用することになりました。

使用参画して運用してみた感想

  • gomock

    参入した当初から使っていたので当たり前になっていましたが、これがない世界線には行きたくないなぁと感じています。高性能なテストダブルがコマンド一発で作られるのはめちゃくちゃ体験がよく、癖はありつつも慣れたらその利便性の虜になりました。

    ただ、呼び出し前に期待値を設定することを強制されるので、そこは柔軟性にかけるように感じています。他言語のテストダブル系のツールだと呼び出した後にそれを検証するというのが一般的なように思っていて、気づかぬうちにテストケースが複雑化していることはしばしばあります。

  • cmp

    参入した当初から使っていたので当たり前になっていましたが、これがない世界線には行きたくないなぁと感じています(n回目)

    差分の見やすさに加え、値の比較時に利用される便利なオプションがたくさん提供されているのも満足度が高いですし、なんなら自分で作ることも容易にできるので、普段使いの利便性と拡張性どちらの特性も併せ持ったヒソカみたいなパッケージだなと思ってます。

まとめ

今回の記事を書くにあたって、導入・運用の二つの観点でそれぞれの技術についてのメリデメだったり、各フェーズでの課題や成功体験をシェアすることができました。意外とこういう機会は少なく、業務の合間合間にさらっと雑談として話されることが多い印象ですが、理解の整理や今後同様のシチュエーションにおける道標となり得る感触があったので、また開催しようと思っています。

同様のテクノロジースタックを検討しているエンジニアの皆さんにとって、この記事が有用な情報源となれたら嬉しいです!

今後も我々の技術選定は続く ——

herp.careers

支払い.comのブランチ戦略見直しとテスト環境を複数作成できるようにした

はじめに

UPSIDER の 支払い.com でバックエンドエンジニアを担当している水村です。今回は支払い.comのブランチ戦略とテスト環境を見直して改善している件をまとめました。

最近のチーム構成

技術境界でチーム分けをしています。フロントエンド、バックエンドをそれぞれ専任で開発しており、OpenAPI によるスキーマ駆動開発を行っています。

現在(2023年11月22日時点)は以下のチーム構成です。

  • フロントエンド4名(フルタイム3名)
  • バックエンド4名(フルタイム2名)

3ヶ月前までは以下のチーム構成で開発をしていました。

  • フロントエンド2名(フルタイム1名)
  • バックエンド3名(フルタイム1名)

小規模なチームのため、ブランチ戦略やテスト環境による悩みはそれほどなかったのですが、幸いなことに正社員でフルタイムのメンバーが増えてきたことにより、今までそれほど問題ではなかったことが徐々に問題となってきました。

開発の流れ

以下のような流れでブランチを作成し、本番リリースを行なっています。Git-Flow に則った開発フローです。

  1. develop ブランチから feature ブランチを作成する
  2. feature ブランチから Pull Request を作成する
  3. Pull Request をレビューし問題なければ develop ブランチにマージする
  4. develop ブランチにマージされたタイミングで develop ブランチがテスト環境に自動でデプロイされる
  5. テスト環境でテストする
  6. リリースしたいタイミングで develop ブランチを main ブランチにマージする
  7. GitHub の画面上からタグを作成して main ブランチをリリースする(フロントエンド、バックエンドは個別にデプロイ可能)

ブランチ戦略とテスト環境の課題

支払い. comは初期から git-flow で開発を行なっています。テスト環境は1つで、develop ブランチにマージされると自動でテスト環境にデプロイされます。ブランチ戦略については何度かチーム内で話し合いを行なっており、将来的には GitHub-Flow に変わるかもしれません。現在は以下の課題を認識しているので改善を行なっている最中です。

  1. 大きめの機能を develop にマージした場合、テストが終わるまでの間は develop にマージできない場合がある
  2. テスト環境が1つしかなく、並行してテストがやりづらい

リリース初期から開発メンバーがあまり増えていなかったことと、大きめの機能リリースが少なかったことから先ほどの問題があまり重要視されていませんでした。ですが、8月ごろからエンジニアが増え、開発する機能数も増えてきたため、ブランチ戦略などの見直しを行った方が良さそうとなりました。

ブランチ戦略見直しと複数のテスト環境をデプロイする

先ほどの課題を同時に解決するため、最近は次の対応をおこなっています。

※前提として、支払い. comのフロントエンドは Next.js を Firebase Hosting にデプロイしています。バックエンドは Server-Side Kotlin を Cloud Run にデプロイしています。

  1. 大きめな機能は feature ブランチとは別に scope ブランチを作成し、feature ブランチを scope ブランチにマージする
  2. Firebase Hosting の preview 機能を使って Pull Request ごとにフロントエンドのテスト環境を作成する
  3. Cloud Run の deploy preview 機能を使って Pull Request ごとにバックエンドのテスト環境を作成する(こちらについてはまだ完全にできているわけではないので対応中)

これらの対応によって、develop ブランチが長期間占有されてしまうという課題を解消し、複数の機能を並行してテスト可能になると考えています。まだ全ての機能が対応できているわけではないですが、一部の機能については develop ブランチとは別のテスト環境を用意して並行でテストができるようになりました。

develop ブランチから main ブランチに対して 特定の機能のみを cherry-pick してリリースするという案もありましたが、cherry-pick でのリリースは失敗するリスクが高いと判断したためやめました。

scope ブランチを作成するデメリットは develop ブランチへのマージが遅くなることです。develop へのマージが遅くなることによりソースコードのコンフリクトが発生する確率が上がります。よって、scope ブランチは定期的に develop ブランチをマージし、コンフリクトが発生しないようにした方が良いかもしれません。また、scope ブランチが長期間存在しないようにする工夫が必要になるかもしれません。

バックエンドのテスト環境を複数デプロイできるようになりましたが、テスト環境が接続するデータベースは1つにしています。理由は、複数データベースをセットアップするのが結構大変であること、銀行振込などに使用している外部サービスが指定した IP アドレスのみの接続を許可しているため、全部の機能をテストできるのは develop ブランチがデプロイしたテスト環境のみという制約があるためです。

バックエンドの Cloud Run コンテナは Cloud NAT を使用して外部 IP アドレスを固定しています。複数デプロイした Cloud Run の外部 IP アドレスを同じものにするにはもう少し工夫が必要そうです。

本番リリースの方針

今の所あまりルールはないですが、基本的に以下の方針でリリースしています。

  • 週末、深夜、土日は避ける
    • ただしシステム停止が必要な機能は利用者の少ない週末などにリリースすることを検討する

Cloud Run は割といい感じにシャットダウンしてくれるので、日中にリリースすることが多いです。データベースのマイグレーションは手動で行なっています。データベースのマイグレーションを自動にした場合、思わぬ不具合が発生することがあるので念の為手動で行うようにしています。データベースのマイグレーションが伴う本番リリースについても、本番環境の影響を考慮しなくても良いと判断できるものについてはシステムを停止せずにリリースするようにしています。

フロントエンドに関しては、画面のみの軽微な修正などに関してはバックエンドのことを気にせずにリリースしています。APIスキーマ定義が変更になる場合などは先にバックエンドからリリースし、その後フロントエンドをリリースするようにしています。

GitHub の画面上でリリースタグを作成すると本番リリースされるようになっていて、リリースが完了すると Slack に通知が飛びます。フロントエンド、バックエンドをそれぞれ個別にリリース可能です。

リリースが完了すると Slack に通知されます

もっと簡単に、かつ、より安全にリリースしたいと考えているので、現在の monorepo 構成からフロントエンドとバックエンドで repository を分割した multi-repo にした方がいいのではないかという話を過去に行なっています。

monorepo で行くのか multi-repo にするのか

私たちのチームは、現在の一元化されたリポジトリ(monorepo)構成を維持するか、複数のリポジトリ(multi-repo)に分割するかを議論しています。どちらがベストかは明確ではないため、まずは新しいプロジェクトでmulti-repoを試すことにしました。

monorepo の利点には、チームの一体感、バックエンド開発者がフロントエンドの Pull Request を確認しやすいこと(もちろん、逆のパターンもあります)、そしてフロントエンドとバックエンドの両方が同じコミットで機能することを保証しやすいことがあります。

これらの点から、monorepo の継続も良い選択だと考えています。一方で、multi-repoにすると、フロントエンドチームはその領域に集中でき、バックエンドの変更がなければ気にせずリリースできるため、どちらが私たちに適しているかを検証したいと思います。

支払い.comのような技術的境界でチームが分かれている場合、フロントエンドとバックエンドを別々のリポジトリにするとリリースが容易になると考えています。しかし、実際に運用してみないと分からないこともあるため、様々な方法を試しながら、私たちに合った開発フローを見つけたいと思います。

トランクベース開発など、すでに確立された開発フローに基づいて進めつつ、細部を微調整して自分たちにとって最適な方法にする予定です。

開発フローやブランチ戦略などを考え直して分かったこと

まず、自分1人では思い付かないような改善提案がチームメンバーからたくさん出てくることがとてもいいなと思いました。2週間に1回やっている KPT でブランチ戦略の見直しやテスト環境を複数デプロイする案などが出てきており、実際に Try して改善できているので、今後も継続していきたいです。

ちなみに Firebase Hosting と Cloud Run の preview 環境デプロイはチームのエンジニアが積極的に動いて全部やってくれました!圧倒的感謝。

現在採用している Git-Flow を GitHub-Flow に変更するかどうかも検討しており、GitHub-Flow を採用するのであれば feature flag を使ってリリースできた方が良さそうなので、今後も開発体験を損なわず、かつ、リリース速度を落とすことなく継続的に改善していけたらいいなと思います。

こちらもモザイクだらけですが、KPT で上がってくる Problem の画像になります。Problem の中から厳選して Try を選ぶことが多いです。

毎回大量の Problem が出てくる KPT, Try は1-2個に絞ります

できていないこと

計測ができていないです。リリースしたことによりビジネス的にどのようなインパクトがあったのかをちゃんと計測する必要があると考えています。具体的にはリリースした機能によって売り上げがどれぐらい上がったのかや、顧客の利便性がどのぐらい上がったのかなどを定量的に計測したいと考えています。計測できていることも一部ありますが、できていないことの方が多いです。

また、システムのメトリクス収集については Google Cloud の Cloud Monitoring を使っているだけなので、システムの規模が大きくなってより詳細なメトリクスなどが必要になった場合は Datadog などの導入が必要かもしれません。

Cloud Run の場合は Open Telemetry などを導入しなくても Cloud Trace で API の処理単位で何にどれぐらい時間がかかっているのかを計測できるので、現状はそれで十分かなという状態です。Cloud Trace とは別に Cloud Monitoring のダッシュボードで CPU 使用率やメモリの使用量、コンテナインスタンス数などを確認しています。

支払い.comはマイクロサービスではないため、 Cloud Run の出力するログを Cloud Trace が解析し、それぞれの処理時間を出力することができています。今後新たにマイクロサービスを構築し、サービス間でトレースを行いたいなどのニーズが出てきた場合は Open Telemetry などのライブラリの導入が必要になりそうです。

それ以外にも、ガベージコレクションの回数など、 JVM の詳細なメトリクスを収集して計測したくなった際は Open Telemetry の導入を検討しようと思います。

プロジェクトの運営についても慣れが必要かなと感じています。エンジニアが増えたことにより調整と管理のコストが増えたので、管理や調整のコストを最小限に抑えつつ最速でプロダクトをリリースできるようにしていきたいと考えています。具体的な改善案などは振り返りの時に考えようと思います。

Cloud Trace で処理ごとの時間を表示できる

おわりに

人が増えて開発する機能が増えると今までそれほど問題ではなかったことが本格的な問題になるという話でした。scope ブランチから preview 環境へデプロイすることで複数環境でテストできるようになる見通しです。さらに人が増えたら別の問題が発生すると思いますが、継続的に改善していけたらいいなと思います。

このテックブログは、私一人の功績ではなく、チーム全体で継続的に改善してきた成果を紹介しています。チームや事業が成長する過程で直面した様々な課題について、リリース後の約2年間を振り返り、ここにまとめました。これからもチームとして成果を上げられるように頑張ります!

これは個人的な感想でもあるのですが、他の会社のテックブログなどを読むと強いエンジニアがいい感じの環境を整えて理想的な開発をできていそう・・というふうに見えることが多いです。このブログもそのような印象を持っていただけると嬉しいのですが、正直なところ、支払い. comの開発チームはできていないことや不十分なことはまだまだあります。このブログだけで全てを伝えることは非常に困難なので、もし気になることなどがありましたらお気軽にご連絡いただければと思っています。

UPSIDERはエンジニアを募集していますので、ご興味がありましたらカジュアル面談などのご応募をお待ちしております!

herp.careers

支払い.com のアジャイル開発を基本としたスクラムではない開発手法について

はじめに

UPSIDER の支払い.comでバックエンドエンジニアを担当している水村です。

リリース当初はビジネスサイドとエンジニアを合わせて5、6名だった支払い.comのチームですが、リリースしてから2年弱でビジネスサイドを含めると数十名規模になりました。

今回は、アジャイル開発を基本としていますが、スクラム開発ではない支払い.comの開発手法についてまとめました。

支払い.comのリリースフロー

2022年5月に正式スタートした支払い. comですが、2023年11月22日現在、本番環境へのリリース回数は394回に達しました。過去1年半の営業日を約370日と考えると、ほぼ毎日リリースしていることになります。毎日リリースが行われるわけではありませんが、時には1日に複数回リリースすることもあります。開発チームの人数が多くないにも関わらず、この頻度でリリースができていることは良い成果だと思います。

さらに、これまでのリリースは特に大きなバグも少なく、安定して行われています。たまに私が間違えてリリースすることもありましたが、そうしたやらかしは2回程度で、幸い本番環境への影響はありませんでした。翌日にリリースしようと思っていた軽微な修正を早めにリリースしてしまった、という感じです。

支払い.comはスクラム開発を採用していないので、スプリントがありません。ですが、アジャイル開発やスクラム開発の考え方を基本としています。機能が出来上がってテスト環境でテストして問題なければリリースという流れで本番リリースを行なっています。本番リリースまでのリードタイムを短くした方がメリットがあると考えているため、リリース準備が整った機能はどんどんリリースしています。これは和田卓人さんの「質とスピード」や「Lean と DevOps の科学」などから影響を受けています。

リリース頻度が高いことのメリット

良い意味で本番リリースの緊張がなくなることが最大のメリットだと感じています。リリース頻度が2週間に1回だったり、月に1回だった頃と比べても安心してリリースできている実感があります。小さな変更や機能追加を頻繁にリリースできるのでテストする範囲も狭いです。テストもすぐ終わります。いいことづくめなので頻繁にリリースしたくてたまらなくなってきます。

リリース頻度を高めるためにやっていること

当たり前のことすぎるので書く必要なさそうですが、GitHub Actions 上の CI でテストを自動化して CD で簡単にデプロイできるようにしてます。CI は早く終わったほうが嬉しいので定期的に見直して遅くなったら改善します。

Pull Request を作ると CI が自動で走る。テストは並列化されている。

本番リリースも GitHub Actions 上で実行されています。バックエンドの場合は Docker Image を作成して Artifact Registry に push して Cloud Run へのデプロイが実行されます。フロントエンドの場合は GitHub Actions 上で Next.js のビルドを実行し Firebase Hosting にデプロイします。

リリースについての基本的な考え方ですが、最小限の機能や変更を細かくリリースするようにしています。「GitLabに学ぶ 世界最先端のリモート組織のつくりかた」に書いてある考え方と近いかもしれません。この本は最近読みましたが、参考になることが多かったです。

スプリントをやらない理由

スプリント方式を採用しない理由は、主にリードタイムが長くなることにあります。スクラム開発では、1-2週間のスプリントを実施し、その期間内にユーザー受け入れテスト(UAT)まで完了しリリースできる状態にすることが目標です。しかし、実際には1つのスプリント内で UAT を完了させるのは難しく、多くの場合次のスプリントに持ち越されます。これにより、機能が本番環境にリリースされるまでには最短で1〜2週間の遅延が発生します。

さらに、スプリント内でリリース対象の機能が多い場合、テストと本番リリースが困難になることがあります。テストで多くのバグが見つかった場合、機能を元に戻したり(revert)、特定の機能を選んでリリースしたり(cherry-pick)する必要が出てきます。また、スプリントの期限に合わせて品質を犠牲にしたコードがマージされることもあり、これは開発の負債となり得ます。

確かにスプリント方式が有効なケースもありますが、現在の私たちの開発体制ではその必要性が低いと感じています。スプリントによって生じる遅延や複雑さを考慮すると、私たちにとって最適な方法ではないと判断しました。

よって、支払い.comではカンバンのような開発方式を採用してます。エンジニアの手が空いたら優先度の高いタスクを順番にやっていくようにしています。

そもそもなぜスプリントが必要なのかを考えると、ステークホルダに対して進捗を伝えやすいことが大きな理由だったりします。UPSIDERのように自社でサービスを運営している会社の場合、進捗に対して説明責任が必要な外部のステークホルダがあまりいないです。

開発の細かい進捗は支払い.comのチーム内で共有できていればよく、リリースタイミングは自分たちでほぼ自由に決めることができます。そのため、スプリントを採用する必要性は、一般的な環境とは異なるかもしれません。スプリントプランニングとは別に、顧客に何を提供したいのかを機能単位で判断して優先順位づけを行なっています。

プロダクトバックログ

以前はプロダクトバックログGitHub Projects で管理していたのですが、エンジニア以外のメンバーが見づらかったりしたので notion に移行しました。notion に移行するにあたってLayerX さんのテックブログが参考になりました。ありがとうございます!

モザイクだらけですが、要件と仕様とタスクを notion で管理しています

プロダクトバックログ上で優先度の高いものから要件と仕様を詰めていきます。ビジネスサイドの希望するリリース日が実現可能かどうかの見積もりをエンジニアが行います。見積もり精度はあくまで目安であり、70%の確率で2-3日で完了、90%の確度で4-5日といったブレがあるので、見積もり段階で決定したリリース日からずれることが想定されます。

個人的には見積もり段階でいつ頃リリースできそうかを判断し、実装途中でさらに精度が上がってくると思うので、途中経過を報告していつ頃リリースできそうかをビジネスサイドと調整するのがいいのかなと考えています。また、見積もりはあくまで見積もりであり、コミットメントではないという前提で考えています。

リリース日をどうやって決定するのかは現在改善を行なっている最中です。ビジネスサイドの要望としてはなるべく早い段階でいつリリースできるのかを知りたいのですが、エンジニアとしては見積もりや実装途中までやってみないとなんとも言えない部分があるので、ビジネスサイドとエンジニアのお互いにとってどのあたりで合意を取るのが最適なのかを模索しています。

おわりに

支払い.comは元気な20代前半の若者が PjM, PdM, PMM を担当しています。エンジニアチームも20代中盤から40代、元 SIer や受託開発経験者など、多様性があります。最近の若者は非常に優秀で、めちゃくちゃ優秀ですごいな・・というのを感じながら若者の成長を見守りつつ、顧客にとって価値のある機能を最速でリリースできるように努めてまいりますので、よろしくお願いいたします。

UPSIDERはエンジニアを募集していますので、ご興味がありましたらカジュアル面談などのご応募をお待ちしております!

herp.careers

参考文献

アジャイル開発やスクラム開発についての考え方は以下の本などを参考にしています。

アートオブアジャイルデベロップメント

エッセンシャルスクラム

エクストリームプログラミング

アジャイルな見積もりと計画作り

アジャイルサムライ

アジャイルレトロスペクティブズ 強いチームを育てる「ふりかえり」の手引き

(冊子版)ふりかえり読本 実践編~型からはじめるふりかえりの守破離~

Lean と DevOps の科学

アートオブプロジェクトマネジメント

UPSIDERのフロントエンドをNuxtからNextへリプレイスしている話

はじめに

こんにちは、株式会社UPSIDERでフロントエンドチームに所属している久保です。

今回は、リリースから走り続けてきた法人カード「UPSIDER」(以下、UPSIDER)のフロントエンドを、NuxtからNextにリプレイスしている話を共有します。

リプレイスに至った背景

背景はいくつかあるのですが、主にNuxt3へのリプレイスコストが大きかったことが背景としてあげられます。UPSIDERはNuxtの2系で実装されており、TypeScriptを使用するためにvue-property-decoratorを使用しています。Nuxt3では元々破壊的変更が大きい中で、classベースのコードからNuxt3へのリプレイスコストは払えないという判断をしました。仮に、Nuxt3にリプレイスできても3系に対応していないライブラリの再選定も必要になり単純なリプレイスでは収まらない可能性がありました。

Next.jsを採用した理由

まず、リプレイスコストを最小に抑えつつ将来的に実現したいことが可能かどうかを判断軸としました。

リプレイスコストの観点では、vueの構文からjsxになるため1からのコーディングにはなりますが、vueをサポートしているライブラリはreactもサポートしてるものも多く、ライブラリをそのまま流用できるといったことからリプレイスコストを下げられるといった判断をしました。

また、将来的にLPはSSGを使用し静的なものとして配信したいといった構想があるためページ毎にSSGを実現できることは必須でした。

これらの判断を加味し、総合的に見てもエコシステムの充実度や実用例の数からデファクトスタンダードになりつつあるNext.jsを採用しました。

リプレイス後の技術

  • Next.js@13系
  • MUI@5系
  • Storybook@6系
  • react-query@3系
  • zod@3系
  • React Hook Form@7系

また、スキーマ駆動での開発を行なっているため、OpenAPIを用いたスキーマ定義から型定義とapi clientの自動生成も行なっています。

リプレイス戦略

リプレイス方法

UPSIDERは全体で約120のページが存在しています。そのため、全てをリプレイスしてからリリースするという、ビックバンリリースは実装やQA観点からもコストが大きいという判断をし採用を見送りました。他にも、マイクロフロントエンド的にリプレイスしていく案もありましたが、基盤が複雑になることを筆頭に現段階では諸々の恩恵を受けづらいのでは?となりこちらも採用を見送りました。

最終的には、ページ毎にリプレイスをしていくという一般的な手法を採用しました。routingに関しては、本番はIstioを用いてAというpathはNextへ、BというpathはNuxtにroutingさせています。開発環境に関しては、node.jsで簡単なプロキシを立てpathに応じてNextとNuxtにroutingさせています。

Screaming Architectureの導入

リプレイスするにあたりディレクトリ構成も見直しました。Nuxtのディレクトリ構成はファイルタイプで管理していく構成をとっており、ページ数やコンポーネントが多くなるにつれて見通しが悪くなっていました。また、Nuxtではほとんどが規約化されていないことも拍車をかけ、実装者に依存する形になっておりファイルタイプをどこで管理するのかという点においても一貫性がない状態でした。

これらを解決するためにScreaming Architectureを採用しました。(Features directoryとも呼ばれている認識です)

dev.to

簡単に説明すると、対象のFeatureに関連するファイルはFeature内に閉じ込めることを意識してディレクトリを構成しファイルを配置していきます。そのため、関連するファイル群が散らばりにくくなっており、対象ディレクトリの中を見れば、ほとんどはそのディレクトリの中にあるという状態を実現できます。

src/
└── features/
    ├── some-feature-A/
    │   ├── component-A/
    │   │   ├── index.ts
    │   │   ├── use-some-hook.ts
    │   │   ├── type.ts
    │   │   ├── styles.module.scss
    │   │   └── component-A.tsx
    │   ├── component-B
    │   └── component-C
    └── some-feature-B/
        ├── component-A
        ├── component-B
        └── component-C

もちろん、Feature間で共有したいロジックやコンポーネントは存在するため、src/componentsやsrc/utilsなどのグローバルのディレクトリは存在しているのですが、基本的にはFeatureの中で閉じるルールになっているため今の所ディレクトリの肥大化は抑えられています。

スキーマ駆動開発の導入

今までは、APIのドキュメントがなく実装者が直接responseやコードを見て型定義をしていました。新しいAPIに関しては、Notionにスキーマを書いて共有するという形をとれていたのですが、開発者が型定義しないといけないことは変わりはありませんし、時間が経つとスキーマを書いているNotionも見つけづらい状態になっていました。

上記の問題を解決するため、OpenAPIによるスキーマ定義を導入し、定義したスキーマから型とapi clientを自動生成もできるような仕組みを整え、実装者が開発に集中できるような環境を作りました。

また、スキーマのexamplesをjsonに変換する仕組みも整えることにより、フロントエンドがバックエンドの開発に依存しなくなった点も大きなメリットを感じています。

Storybookを使ったInteraction testの導入

今までは、実装したものは全て実装者およびQAで確認作業を行うというフローになっていました。しかし、これは資産にならずプロダクトが肥大化するにつれ確認作業も膨大な量になっていき、確認コストがかかり続けリソースを消費し続けてしまいます。

上記の問題を解決するためNext側ではStorybookを使ったInteraction testを導入しました。

storybook.js.org

導入した結果、当初課題としていたQAリソースの圧迫は軽減に向かっています。また、リファクタにおいても効果を発揮しており、テストが存在することによって安心してリファクタができる環境が整ってきています。

まとめ

「UPSIDERのフロントエンドをNuxtからNextへリプレイスしている話」を共有しました。

Nuxtが支えてきた基盤に敬意を払いつつ、より良いプロダクトを実現するためにNextへリプレイスしています。

今回は概要などの軽い紹介でしたが、今後はより深い部分も紹介できればと思っています。

宣伝

UPSIDERでは、成長企業のための法人カード「UPSIDER」と、すべてのBtoB取引でクレジットカードを利用できる請求書カード払いサービス「支払い.com」を提供しており、どの方面においてもエンジニアの採用を行なっております。

カジュアル面談もやっておりますので、少しでもご興味のある方は、ぜひご連絡ください!

herp.careers

4年続けてきた本番運用を(ほぼ)完全にバトンタッチした話

みなさんこんにちは、UPSIDERでエンジニアをしています清水(通称シミケン)です。

僕はUPSIDERの創業期から関わらせてもらっており、Webシステムの設計・開発をメインでやってきたこともあって最初にプロダクトをローンチした2019年12月以来、ずっと本番運用にも関わってきました。

もちろん、フェーズによって関わり方は徐々に変わってきていたのですが、この度ようやく本番運用に関する業務を(ほぼ)すべて引き継ぎ、他のメンバーに運用をお任せできるようになったため、これまでのプロダクトやチームの変遷も踏まえて自分がどんなことをしてきたのかを振り返ってみたいと思います。

1年目(プロダクトローンチ最初期)

今でこそ非常に多くのお客様から法人カードをご利用いただいていますが、プロダクトローンチ当初、法人カードはまだ提供できておらず、振込処理の自動化機能の提供を行っていました。

(そのあたりの流れは以前執筆させていただいたこちらの記事に詳しく記載しています)

ユーザー様がアップロードされた請求書をデータ化して振込まで一気通貫で行うサービス(現在このサービスは終了しており、別プロダクトとしてローンチした支払い.comに引き継がれています)だったのですが、最初は共同代表の宮城や水野を含む社内メンバー総出で手動でデータの入力を行っていました。

宮城もデータ入力してました

月次バッチの運用を自動化しようとして間に合わなかった模様

こんな感じで、ユーザー様へ新しい価値を届ける機能開発に追われつつちょっとずつ運用効率を改善していったり、まだ存在していない運用上必要な機能をなんとかギリギリで実装したり、色々と泥臭く運用を繋いでいた記憶があります。

ちなみに、当時Webシステムのサーバーサイドの社員は僕一人だったこともあり、システム側の運用はほぼワンオペで回していました。

2年目(法人カードローンチ期)

ついに念願の法人カードをローンチでき、運用タスクもカードに関するものが徐々に増えていきました。

この頃には請求書のデータ化も外部に委託する体制が整っていたため、ようやく社内総出でデータ入力する作業からは解放されたのですが、それでもデータの最終チェック・登録作業は社内で実施していたためまだそのあたりのタスクが残っており、結局ユーザー数の急増&カードに関する運用タスクの増加と相まってあまり業務量としては減っていなかった(というか増えた?)ように感じます。

毎年恒例、あけおめバッチ処理もやってました。

年始に限らず、毎月初に実施するバッチ処理は僕らのサービスの価値を支える一つの要素になっており、

  • 前月の利用実績に基づいて付与されるポイントを、確定できるようになった月初のタイミングで即座に付与し、そのまま翌月の請求金額から差し引けるようにする
  • 毎月の請求額をできるだけ早くユーザーにお伝えする
  • 月が変わったことで初めて連携が可能になる情報を即座に会計システムに連携することで、月次決算の早期締めに貢献する

などの価値を実現するため、月が変わったその夜のうちに処理を実行して、朝までには必要な処理を終えておく必要があります。

完全に自動化できればよかったんですが、上記のとおり朝には処理が終了している必要があるため、不測の事態に備える意味もあって結局今にいたるまで月初処理は必ず人が張り付いて対応しています。

ちなみに、当時Webシステムのサーバーサイドの社員は僕一人だったこともあり、運用はほぼワンオペで回していました。(2年連続2回目)

3年目(運用チーム組成期)

プロダクトローンチから3年目に入って、今まで提供してきた振込処理の自動化機能を終了することが決まり、ここに割かれていた運用リソースがごっそり不要になりました。

そして時を同じくして、ついについに念願のサーバーサイドの正社員メンバーが増え、運用チームが組成されました。

これらによって本格的に本番運用をチームで回していく体制が整っていき、僕自身が運用に割くコストは大幅に減りました。ようやく運用時にバイネームでメンションされまくる時代にさよならすることができたのです。本当に一緒に運用をやっていくことを快諾してくれたメンバーには頭が上がりません。

一方で、法人カードのリリースから一年が過ぎ、本格的に認知度があがってきてユーザー数が一気に増えるようになったのもこの時期です。システムの負荷が上がることによる問題が出始めるだけでなく、それまでは想定していなかったイレギュラーな使い方をされることも増えてきたため、そうしたイレギュラーな利用方法にも対応できるような修正が必要になってきました。

ただ、ここでも運用チームを組成した恩恵があり、徐々に運用改善にコストもかけられるようになりました。上記のようなイレギュラーケースへの対応ができたり、これまではTech側に依頼をしてもらう必要のあったことがビジネスサイドだけで完結できるようになったりと、そういった意味でもチーム化の意味は非常に大きかったです。

ちなみに、毎月恒例の月初バッチだけはしばらく自分が担当していたんですが、2022年10月くらいからそれもお任せするようになりました。(年始のあけおめバッチだけは、なんとなくお願いするのが憚られて自分でやりましたが)

4年目(バトンタッチ期)

そして、ようやくほぼすべての運用をバトンタッチすることができたのはつい最近のことです。

とはいっても、もう今年に入ってからはほとんどの運用タスクをチームにお任せしていたため、残っていたのはほぼ今年リリースした仕訳機能に関するお問い合わせ対応やバグFIXくらいでした。

それもようやく先日必要な引き継ぎを終え、ごく稀に質問をもらう程度でそれ以外の運用タスクはすべてチームが巻き取ってくれています。

おそらく2024年のあけおめバッチも、ついに初めてチームメンバーにお願いする形になるのではないでしょうか。(とかいいつつ何故かまたやっているかもしれませんが)

これまでを振り返って

プロダクトローンチ当初からずっと本番運用をして酸いも甘いも味わってきた身としては、それを他の仲間に任せられるようになって、肩の荷が下りた気持ちが3割、頼もしい仲間に支えてもらえてありがたい気持ちが6割、ちょっとだけ寂しい気持ちが1割といった感じです。

自分にとってこのプロダクトは本当に子供のような存在で、誕生当初は「この対応を〜日までに終わらせないとこのプロダクトは死ぬ」みたいなこともあり、なんとかそれを乗り切るために死にものぐるいでしたが、改めて振り返ると本当に楽しく素晴らしい経験をさせてもらっているなと感じます。

今ではUPSIDERは本当に多くのユーザー様にご利用いただけるプロダクトになりました。属人的な頑張りでなんとか支えていくフェーズはとうに終わりを告げ、これからはさらに安全・安心にご利用いただけるようにより強固な仕組み・組織を構築していく必要があります。

そうした運用面も含め、本当に実現したい価値からはまだまだほど遠い状態ですので、爆速でそうした価値を提供していけるように頑張っていきたいと思います!