kubell Creator's Note

ビジネスチャット「Chatwork」のエンジニアのブログです。

ビジネスチャット「Chatwork」のエンジニアのブログです。

読者になる

自前アーキテクチャなコードを Redux 構成に書き換えているお話

こんにちは、フロントエンド開発部の西口 (cw_nishiguchi) です。

Chatwork はおかげさまで、サービス開始から来年で 10 年を迎えようとしています。 この記事は、その歳月においての Web クライアントのアーキテクチャの変遷をたどるお話になります。

アーキテクチャの変遷

これまでのアーキテクチャを大まかに分けると、

  • サービス開始当初の jQuery 期
  • React 導入期
  • Redux 導入期

となり、現在は Redux へ移行中というステータスです。

便宜的に 3 期に分けて書きましたが、実際には jQuery 期のコードも部分的にですが、まだ現役で動いています。

アーキテクチャの刷新を実際におこなうには、それなりのコストが発生します。また、その間機能開発を止めるわけにもいかないので、エイやで一度に置き換えられないため、部分的に少しずつ機能開発と並行して進めています。という事情もあり、各時代のコードが共存しているわけです。

このように、

  • コストがそれなりに掛かる
  • 一時的(しかし、わりと長期間)にとはいえ古いコードと共存して複雜な状況になる

ということが明らかなアーキテクチャの移行をなぜ決断したのか。そこに至ったのにはいくつかの課題が見えてきたからでした。

自前アーキテクチャ

まず、アーキテクチャ移行の話に入る前に、React 導入を進めていた頃を思い出してみたいと思います。 React 導入期のアーキテクチャは下図のような感じです。

DDD + React アーキテクチャの概略図
DDD + React アーキテクチャの概略図

Domain は、いわゆる DDD の Domain 層で業務ロジックが置かれています。

Application は、View の状態を構築したり、View のイベントを受けて Domain サービスを呼び出したりします。

View は、UI の部分です。

旧コードは、 jQuery 時代のコードで、ACL (腐敗防止層) を介して Domain 層や Application 層とやり取りします。

処理の流れ

処理の流れとしては、

  • 旧コードから ACL を経由して、または Application が View のイベントを受けて、Domain のサービスを呼び出す
  • Domain は、メモリ上のドメインモデルに対して処理をおこなう
  • 処理が完了したら、Application Service が View に渡すデータを構築
  • View が再描画をおこなう

というように、Domain、Application、View そして旧コードの各層が連携して Chatwork のフロントエンドを構成しています。 この中で、アーキテクチャ移行の要因となったのが、Application Service と View をつなぐ Renderer の仕組みでした。

ということで、次にその Renderer を詳しく見ていきましょう。

自前 Renderer

冒頭でアーキテクチャ移行を部分的に進めていると言いましたが、同じような事情、かつ当時はフロントエンドのエンジニアが 2 人しかいなかったので、大まかに機能開発と React 導入とに担当を分けて、React 化も部分的に進めていました。(ちなみに一番最初に React 化されたコンポーネントはヘッダーのロゴ)

少しずつ React 化を進めていくために、各機能ごとに切り出して React 化するという戦略を取り、部分的に React コンポーネントを UI にマウントできるようにしたのが自前アーキテクチャの Renderer でした。

部分的にコンポーネントをマウントするとはどういうことかと言うと、よくある React アプリケーションのように root ノードに対して React コンポーネントをレンダリングするのではなく、各 React コンポーネントを、その表示場所に設置した HTML 要素に対して別々にレンダリングするということをおこなっています。

各コンポーネントに渡す props の生成も、機能単位で設置された Application Service でおこないます。 Application Service には、イベントの処理と、コンポーネントに渡す props を生成するための getRenderParam というメソッドなどが定義されています。

export class SomeFeatureApplicationService extends BaseApplicationService<
  SomeFeatureProps
> {
  constructor(
    render: () => Promise<void>,
    private someDDDService: SomeDDDService, // 依存する DDD サービスを注入。
  ) {
    super(render);
  }

  private someUIState: boolean = false;

  // コンポーネントに渡す props を生成。
  getRenderParam(): SomeFeatureProps {
    return {
      someUIState: this.someUIState,
      onClick: (): void => this.onClick(),
    };
  }

  // UI イベントハンドリング。
  private onClick(): void {
    this.someUIState = true;
    this.someDDDService.someMethod(),
    this.emitRender(); // データ反映後 render を呼び出す。
  }
}

UI で発生したイベントに応じてドメインモデルが更新されると、Render 処理を呼び出します。

async render(): Promise<void> {
  await Render(
    {
      someFeature: this.someFeatureApplicationService.getRenderParam(),
      fooFeature: this.fooFeatureApplicationService.getRenderParam(),
      ****Feature: this.****FeatureApplicationService.getRenderParam(),
      ...
    },
  );
}

render は描画要求を受け取ると、各 Application Service からすべてのコンポーネント用の props を再生成して各コンポーネントに渡し ReactDOM.render で再描画します。

export async function Render(
  param: RenderParam,
) {
  const rendering: Promise<void>[] = [];

  if (document.querySelector('#someFeature')) {
    rendering.push(
      new Promise((resolve): void => {
        ReactDOM.render(
          <SomeFeature {...param.someFeature} />,
          document.querySelector('#someFeature'),
          resolve,
        );
      }),
    );
  }

  if (document.querySelector('#fooFeature')) {
    rendering.push(
      new Promise((resolve): void => {
        ReactDOM.render(
          <FooFeature {...param.fooFeature} />,
          document.querySelector('#fooFeature'),
          resolve,
        );
      }),
    );
  }

  ...

  await Promise.all(rendering);
}

この仕組み自体は、再描画毎に props が再生成されてしまうとはいえ、コンポーネント側で React.memo がされていれば特に問題がないように見えます。

実際 Chatwork でも当初は問題なく動いていました。しかし、開発を進めていく中でいくつかの課題が見えてきました。

課題 1. レンダリングコスト問題

1 つ目に、レンダリングが頻発するという課題がありました。

その要因として、

  • UI の状態が更新されたら Render 処理を明示的に呼び出す必要がある
  • 一部の props が class で実装されている

の 2 点がありました。

Chatwork は比較的規模の大きなアプリケーションなので、状態の更新は割と頻繁に発生します。そのため、アプリケーションのいたるところから立て続けに Render 処理が呼び出され、再描画が多発してしまっていました。

そのレンダリングコストを抑制するために、各コンポーネントの shouldComponentUpdate で props の差分検知をしていますが、再描画毎の props の再生成に副作用が含まれているのと、DDD 周りの class で実装された複雑な props のオブジェクト構造ということから、深い比較が必要になり、差分検知コストが嵩んでしまっていました。

さらに、クラスベースのオブジェクト指向に引っ張られてしまった private なメンバ変数と getter な実装も、差分検知処理コストに影響を及ぼしていました。

課題 2. 独自アーキテクチャ

2 つ目の課題はこのアーキテクチャが独自な仕組みだといことで、Web フロントエンドチームが 2 人から徐々に人が増え大きくなるにつれて顕在化していきました。

新しく加わったメンバーにとって、一般的な Redux に比べると、自前アーキテクチャの学習コストが高くなるのは言わずもがなです。

また、Chatwork のドメインとも密接に連携しているため、業務知識も必要になり、全容を把握して開発できるようになるのに相当な学習の負担がありました。

新しいチームメンバーがいち早く彼らの力を発揮できるようにするには、チームに加わる前に習得した知識・技術が活かせるように、より一般的な Redux への移行が得策だったというわけです。

といった背景があり Redux への移行を決定しました。

Redux 導入

では次に、Redux 導入に関してですが、とは言っても、一般的なアーキテクチャに寄せるというモチベーションで移行しているため、特に変わったことはしていないと思うのですが、あえて取り挙げるとすると、ということでお話したいと思います。

構成

まず Redux 側の Application の構成ですが、アラート機能や Chatwork Live 機能というような大まかな機能単位(Feature と呼んでいます)に分けて、その単位で State を定義しています。

その中で、Chatwork Live 機能の通話をかける / 受けるのように必要な State が異なり分ける必要がある場合は、さらに切り分けて定義し、State の管理単位で Reducer と Container を設置しています。

// DirectCallReducer.ts
export const directCallReducer = createReducer(initialState, builder =>
  builder
    .addCase(startDirectCall, draft => {
      draft.directCallState = true;
    }),
);

// JoinCallReducer.ts
export const joinCallReducer = createReducer(initialState, builder =>
  builder
    .addCase(joinCall, draft => {
      draft.joinCallState = true;
    }),
);

// ChatworkLiveReducer.ts
export const chatworkLiveRootReducer = {
  chatworkLive: combineReducers({
    directCall: directCallReducer,
    joinCall: joinCallReducer,
  }),
};
const localReducer = combineReducers(chatworkLiveRootReducer);
export type ChatworkLiveRootState = ReturnType<typeof localReducer>;
export const useChatworkLiveSelector: TypedUseSelectorHook<ChatworkLiveRootState> = useSelector;
export function JoinCallContainer(): React.ReactElement {
  const { joinCallState } = useChatworkLiveSelector(state => state.chatworkLive.joinCall);
  const dispatch = useDispatch();

  if (joinCallState === false) {
    return null;
  }

  function handleClose(): void {
    dispatch(closeCall());
  }

  return <JoinCall state={joinCallState} onClose={handleClose} />;
}

Feature 単位の管理にすることで、Feature 同士の State の依存を避け、見通しが良くなるようにしています。

また、合成した State に依存すると、Feature 間で循環依存してしまい、コードがクリーンな状態を保てなくなってしまうという理由もあります。

今の所、この構成でも問題はありませんが、今後 Feature 間で State の依存が発生してしまう可能性も 0 ではありません。そうなった時にどうするかですが…

  • Feature 間で依存してしまう State を 持つコンポーネントを props として注入する
  • State を取得する customHook のインターフェースを定義して Container Component に注入する
  • Container Component を Feature から出して Feature を統合するレイヤーで定義する

などの対策が必要になりそうです。

部分的に Redux 化

機能開発と並行して部分的に進めていた React 化ですが、その流れは Redux 移行においても変えることはできないので、Redux 移行も部分的に進めています。

Redux 側は、root 要素にレンダーされた root component 内で createPortal を使って部分的にコンポーネントを UI にマウントしています。

// 必要な確認をしてPortalを作成する。
function createPortalWithCheck<Props>(
  Component: React.ComponentType<Props>,
  container: Element | null,
): React.ReactPortal {
  return (
    container &&
    ReactDOM.createPortal(<Component />, container)
  );
}

function RootApplication({
  param,
  theme,
}: RootProps): React.ReactElement<RootProps> {
  return (
    <ReactRedux.Provider store={param.store}>
      <ThemeProvider theme={theme}>
        <>
          {[
            createPortalWithCheck(
              AlertContainer,
              document.querySelector('Alert'),
            ),
            createPortalWithCheck(
              SomeFeatureContainer,
              document.querySelector('SomeFeature'),
            ),
          ]}
        </>
      </ThemeProvider>
    </ReactRedux.Provider>
  );
}

依然、既存の Render 処理内でのマウントにはなりますが、次のような課題があったために、Redux 側は root component でまとめる形にしています。

ひとつに、全コンポーネントに共通のテーマ用の props や store を各コンポーネントに流し込むために Provider でラップしないといけなかった。

もうひとつは、これまでの React 側のコードだと React 開発者ツールで別々のツリーでの表示になってしまい、微妙にデバッグがやりづらかったからです。

まとめ

React 導入時に実装した State 管理用の自前アーキテクチャにいくつか課題が見えてきたので、Redux への置き換えを決断して移行施策に取り組んでいる、というお話をしてきました。

コンウェイの法則とは違いますが、チームの状況の変化 (われわれの例ではメンバーが増えてチームが大きくなっていく) で、適切なアーキテクチャというのは変わってくる、ということが言える一例かなと思います。

アーキテクチャ刷新の一例

今回ご紹介したのは、DX (Developer Experience の方。念の為。) の改善を観点にした取り組みとなりますが、これ以外にも機能追加や使い勝手の改善をより早くリリースできるような、ユーザーへの価値提供に焦点を絞ったアーキテクチャ・開発体制の刷新も計画しています。

ということで、現在進行中の施策も計画中の施策も、どれもチャレンジングで個人的には楽しみが尽きない環境ではないかと感じています。

そんなわれわれのチームでは、一緒に働いてくれる仲間を募集中です。チャレンジングでエキサイティングな開発をしたいフロントエンドエンジニア集まれ!

[中途採用] hrmos.co

[新卒採用] hrmos.co