エムティーアイ エンジニアブログ

株式会社エムティーアイのエンジニア達による技術ブログ

MicroFrontends について調査しました

はじめに

こんにちは! エンジニアの高橋です。

みなさんは、Micro Frontends (以下マイクロフロントエンド)という言葉は聞いたことはありますか? 自分は、数年ほど前から話題になっているキーワードとして聞いたことはありましたが、「フロントエンドのマイクロサービス化」程度の認識しかありませんでした。

では、マイクロフロントエンドとマイクロサービスの違いはなんでしょうか? また、マイクロフロントエンドのアプローチを採用することで、具体的にどういったメリット・デメリットがあるのでしょうか?

今回は、マイクロフロントエンドについて、調査した結果をまとめてみたいと思います。

概要

マイクロフロントエンドという言葉はどのようにして生まれたのでしょうか? 調べたところ、マイクロフロントエンドという言葉が初めて世で使われたのは、2016年のようです。 提唱者とも言える、ThoughtWorks 社の記事がこちらです。

ThoughtWorks 社による定義は年々変化していて、下記が2020年5月の段階での定義となります。

We've seen significant benefits from introducing microservices, which have allowed teams to scale the delivery of independently deployed and maintained services. Unfortunately, we've also seen many teams create a front-end monolith — a large, entangled browser application that sits on top of the back-end services — largely neutralizing the benefits of microservices. Micro frontends have continued to gain in popularity since they were first introduced. We've seen many teams adopt some form of this architecture as a way to manage the complexity of multiple developers and teams contributing to the same user experience. In June of last year, one of the originators of this technique published an introductory article that serves as a reference for micro frontends. It shows how this style can be implemented using various web programming mechanisms and builds out an example application using React.js. We're confident this style will grow in popularity as larger organizations try to decompose UI development across multiple teams.

自分なりに要約すると、

マイクロサービスアーキテクチャを採用することによって、多くのメリットを享受出来るようになった。しかし、フロントエンドにおいては多くのチームが依然としてモノリスを採用していて、バックエンドのマイクロサービス化のメリットを無効にしていることがわかった。一つのプロダクト開発における、複数の開発者やチームの複雑性を管理する方法として、マイクロフロントエンドというアプローチの人気が高まっている。

となります。

マイクロフロントエンドが、複数の開発者やチームの複雑性を管理するためのものということは見えてきました。

では、もう少し具体的な内容を見ていきましょう。

背景

プロダクト開発において、チームというのは非常に重要なファクターです。 近年のソフトウェアは、従来に比べて複雑性が増し、これによって機能開発に複数チームが連携し合って開発を進めていくことが多いかと思います。

では、プロダクト開発をしていく中で、どのようにチーム構成は変化していくのでしょうか? そのヒントはコンウェイの法則にあります。

コンウェイの法則とは、

「システム設計は、組織構造を反映させたものになる」

という内容で、システム設計と一致するような組織構造を作る、というような考え方を逆コンウェイの法則と呼んだりもするようです。

下記の図から、プロダクト開発におけるチームとアーキテクチャの関連性を見ていきましょう。

f:id:mti-techblog-writer:20210412105549p:plain

モノリス

プロダクト開発において、初期開発のフェーズでよく採用されるのがモノリスです。 この図の中の一番左に当たります。

f:id:mti-techblog-writer:20210412105603p:plain

モノリスでは、規模が小さいケースが多いこともあり、チーム構成としても図のように1チームで開発を進めていくケースが多いかと思われます。 規模が小さいうちは問題ないですが、規模が大きくなるにつれ、プロダクトの複雑性が増し、開発が難しくなってきます。 モノリスのままチームをスケールさせると、複数チームがお互いを気にしながら連携を取らなければならなくなるのは、想像に難くないでしょう。

フロントエンド/バックエンド

モノリスの問題点を解消する構成として、フロントエンド/バックエンドの構成があります。 この図の中の真ん中に当たります。

f:id:mti-techblog-writer:20210412105611p:plain

フロントエンド/バックエンドの構成では、WebAPI を用いて通信を行うことで、フロントエンドコードから、バックエンドコードを切り出すことが可能となります。 これにより、チーム構成としても、フロントエンドチーム・バックエンドチームのように職能型の構成を取ることが出来るようになります。 こういったチーム構成をFunctional Team と呼びます。

Functional Team の構成を取ることで、開発者が自分の得意な領域に注力することが出来るようになるといったメリットがあります。 しかし、デメリットとして、複数チームが連携しないと1機能の開発が完結しないといったことが挙げられます。 また、プロダクト開発におけるバックエンドコードは複雑になりがちなので、同一チームがそれら全てを管理することに難しさが生じます。

マイクロサービス

このような、バックエンドの複雑さを解消するための設計がマイクロサービスです。 この図の右に当たります。

f:id:mti-techblog-writer:20210412105619p:plain

マイクロサービスにおいては、バックエンドコードをサービスという小さな単位に切り出します。 例えば、EC サイトのようなプロダクトでは、商品サービス、レコメンドサービス、買い物カゴサービス、支払いサービス、などが例として挙げられます。 各サービスは他サービスと主に非同期で通信を行うことで成り立っていて、それぞれの依存を最小限になるように構成されるのが、良い設計とされています。 これらの設計のプラクティスとして、DDDイベント駆動開発などがありますが、今回は割愛します。

バックエンドをマイクロサービス化することによって、チームもサービス単位で配置することが出来るようになります。 これによって、各チームは自チームのサービスの機能開発に注力することが出来るようになります。 こういったチーム構成をFeature Team と呼びます。

ここまでが、モノリスのマイクロサービス化です。 では、マイクロサービスを採用することによって、複雑さの管理の難しさから完全に解放されたのでしょうか?

答えはNO です。 Functional Team の話の中で、複数チームが連携しないと1機能の開発が完結しないというデメリットを挙げました。 バックエンドに関しては、各チームが機能開発に注力出来るようになりましたが、フロントエンドは依然としてモノリスのままであるケースが多く存在していました。 そのため、1機能の開発において、フロントエンドチームと、バックエンドの1チームが連携を取る必要があります。 つまり、フロントエンド開発者は、BFFGraphQL などのAggregation Layer を通じて、全てのバックエンドサービスを管理する必要がありました。

マイクロフロントエンド

そこで登場したのがマイクロフロントエンドです。 考え方は至ってシンプルで、フロントエンドまで一貫したマイクロサービスとして構築し、それぞれのサービスにフロントエンド・バックエンド開発者を配置することで、完全なFeature Team を目指す、といったものです。

f:id:mti-techblog-writer:20210412105741p:plain

メリット・デメリット

では、マイクロフロントエンドのメリット・デメリットはなんでしょうか?

メリット

マイクロフロントエンドを実践することで、下記のようなメリットがあると考えられます。

  • 各チームは自チームが実現すべきミッションに注力することができる
    • 各チームは自チームで技術スタック(プログラミング言語やフレームワーク)を選択することができる(プロダクト全体でフロントエンドフレームワークは統一したほうが良いという話もあるので注意!)
    • Feature Team として、機能開発がチーム内で完結する
    • 他チームの詳細な設計をキャッチアップする必要がない
  • 耐障害性の高いWeb アプリケーションを構築することができる
    • 1サービスで障害が発生してもアプリケーション全体が動かなくなることがない

デメリット

また、下記のようなデメリットがあると考えられます。

  • 実現方法が難しい
  • 各チームメンバーの技術スタックが揃わないため、チーム間の異動がしづらい
  • git リポジトリが増える

基本的には、マイクロサービスのメリット・デメリットと一致していますね。 これらのデメリットを補うためのメリットを享受出来る条件として、それなりの規模のアプリケーションかつそれなりの技術スタックを持ったメンバーであるという前提条件があるのだと感じました。

実現方法

マイクロフロントエンドは、具体的にどのようにして実現されるのでしょうか?

ThoughtWorks 社の記事でも紹介されていますが、こちらの記事に、具体的な実現方法が紹介されています。

記事の中で紹介されている実現方法は5つあります。

  • Server-side template composition
  • Build-time integration
  • Run-time integration via iframes
  • Run-time integration via JavaScript
  • Run-time integration via Web Components

結論から言うと、推奨されるのは、

Run-time integration via JavaScript

のようです。 これについてもう少し詳しく解説していきます。

設計概要

解説の前に、もう少し具体的な設計概要について見ていきます。

この図は、 "Feed me" という架空のレストランサイトを題材として、マイクロフロントエンドの構成を説明した図です。

f:id:mti-techblog-writer:20210412105647p:plain

マイクロフロントエンドを採用したアプリケーションは、単一のContainer を持ち、Container が各マイクロフロントエンドサービス(もしくは単にマイクロフロントエンド)を持ちます。

アプリには単一のindex.html が存在し、それらの中に各パーツが含まれている、というようなイメージです。

上記前提で話を進めていきます。

Run-time integration via JavaScript

これは、各マイクロフロントエンドが、JavaScript の <script> タグによってページに含まれ、ロード時にそのエントリポイントとしてグローバル関数を公開するといったやり方です。

他のやり方もありますが、JavaScript を使ったこのやり方が最も一般的だとされています。

原始的なやり方ではありますが、各マイクロフロントエンドを個別にデプロイ可能であることや、統合に柔軟性があることから、採用されるケースが多いようです。

React での実現方法

記事の中では、これをReact を使って実現する方法が紹介されていました。

具体的なソースコードも公開されています。

この記事では、ほんの少しだけソースコードの中身を紹介して終わりにしたいと思います。

上記のソースコードでは、5つのリポジトリが存在しています。

  • container: コンテナー(アプリケーションのエントリーポイント)
  • content: 静的コンテンツ置き場
  • restaurant-order: 注文マイクロフロントエンド
  • browse: 検索マイクロフロントエンド
  • infra: TerraForm を使ったAWS へのデプロイコード

今回はこのうち、下記の2つのリポジトリの実装を見ていこうと思います。

  • container
  • browse

Container 側の実装

はじめに、エントリーポイントとなる、container リポジトリから見ていきます。

index.htmlindex.js です。

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="theme-color" content="#000000">
    <link rel="stylesheet" href="%REACT_APP_CONTENT_HOST%/style.css"></link>
    <link href="https://fonts.googleapis.com/css?family=Francois+One|Yanone+Kaffeesatz" rel="stylesheet">
    <title>🍽 Feed me</title>
  </head>
  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>
    <!-- ここにアプリケーションが配置される -->
    <div id="root"></div>
    <script src="%REACT_APP_CONTENT_HOST%/react.prod-16.8.6.min.js"></script>
    <script src="%REACT_APP_CONTENT_HOST%/react-dom.prod-16.8.6.min.js"></script>
  </body>
</html>

index.js

import 'react-app-polyfill/ie11';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { unregister } from './registerServiceWorker';
import './index.css';

// id='root'のオブジェクトにアプリケーションを配置する
ReactDOM.render(<App />, document.getElementById('root'));
unregister();

色々import されてますが、基本的なReact のエントリーポイントですね。 特にマイクロフロントエンドに特化した部分はなさそうです。

次にApp.js

App.js

import React from 'react';
import { BrowserRouter, Switch, Route, Redirect } from 'react-router-dom';
import AppHeader from './AppHeader';
import MicroFrontend from './MicroFrontend';
import About from './About';

const {
  REACT_APP_BROWSE_HOST: browseHost,
  REACT_APP_RESTAURANT_HOST: restaurantHost,
} = process.env;

let numRestaurants = 0;

fetch(`${process.env.REACT_APP_CONTENT_HOST}/restaurants.json`)
  .then(res => res.json())
  .then(restaurants => {
    numRestaurants = restaurants.length;
  });

const getRandomRestaurantId = () =>
  Math.floor(Math.random() * numRestaurants) + 1;

// Browse マイクロフロントエンド
const Browse = ({ history }) => (
  <MicroFrontend history={history} host={browseHost} name="Browse" />
);

// Restaurant マイクロフロントエンド
const Restaurant = ({ history }) => (
  <MicroFrontend history={history} host={restaurantHost} name="Restaurant" />
);

const Random = () => <Redirect to={`/restaurant/${getRandomRestaurantId()}`} />;

const App = () => (
  <BrowserRouter>
    <React.Fragment>
      <AppHeader />
      // ここでページ単位で各マイクロフロントエンドにルーティングされる
      <Switch>
        // '/' のパスでアクセスするとBrowse マイクロフロントエンドへ
        <Route exact path="/" component={Browse} />
        // '/restaurant/:id' のパスでアクセスするとRestaurant マイクロフロントエンドへ
        <Route exact path="/restaurant/:id" component={Restaurant} />
        <Route exact path="/random" render={Random} />
        <Route exact path="/about" render={About} />
      </Switch>
    </React.Fragment>
  </BrowserRouter>
);

export default App;

MicroFrontend クラスが登場していますね。 Router 的には、

  • /
  • /restaurant/:id
  • /random
  • /about

の4つのRoute がありますが、そのうち、マイクロフロントエンド化されているのは、//restaurant/:id の2つのようです。

では、MicroFrontend クラスを見ていきます。

MicroFrontend.js

import React from 'react';

class MicroFrontend extends React.Component {
  // コンポーネントの配置時に、renderMicroFrontend メソッドを呼ぶ
  componentDidMount() {
    const { name, host, document } = this.props;
    const scriptId = `micro-frontend-script-${name}`;

    if (document.getElementById(scriptId)) {
      this.renderMicroFrontend();
      return;
    }

    fetch(`${host}/asset-manifest.json`)
      .then(res => res.json())
      .then(manifest => {
        const script = document.createElement('script');
        script.id = scriptId;
        script.crossOrigin = '';
        script.src = `${host}${manifest['main.js']}`;
        script.onload = this.renderMicroFrontend;
        document.head.appendChild(script);
      });
  }

  componentWillUnmount() {
    const { name, window } = this.props;

   // (コンポーネントのunmount 時に呼ばれる)
    window[`unmount${name}`](`${name}-container`);
  }

  // コンポーネントの配置時に、conponentDidMount メソッドから呼ばれる
  renderMicroFrontend = () => {
    const { name, window, history } = this.props;

    // window オブジェクトを経由して、render${name} メソッドが呼ばれる (name はマイクロフロンエンド名)
    // ブラウザの操作履歴はcontainer 側で管理して渡す
    window[`render${name}`](`${name}-container`, history);
  };

  render() {
    return <main id={`${this.props.name}-container`} />;
  }
}

MicroFrontend.defaultProps = {
  document,
  window,
};

export default MicroFrontend;

Class Component として作られているようです。 props として、

  • name: マイクロフロントエンド名
  • host: ホストのPath
  • history: ブラウザの履歴情報
  • document
  • window

を受け取っています。

host については、環境変数に設定されています。

.env

REACT_APP_BROWSE_HOST=http://localhost:3001
REACT_APP_RESTAURANT_HOST=http://localhost:3002
REACT_APP_CONTENT_HOST=http://localhost:5000

これを見るに、同じローカルホストの中で、コンテナー・マイクロフロントエンド単位で別ポートで起動し、それぞれを行き来するような形でアプリケーションが実現されるようですね。

history については、ブラウザの履歴管理になるため、container が所有しています。

componentDidMount メソッド内で、renderMicroFrontend メソッドが呼ばれています。 renderMicroFrontend メソッド内では、window オブジェクトに格納された各マイクロフロントエンド用のメソッドを呼んでいるようです。 window オブジェクトはグローバルかつ一般的なので、グローバルメソッドの管理場所として使っているということですね。

マイクロフロントエンド側の実装

では、実際にマイクロフロントエンドの中身となる、browse リポジトリを見ていきましょう。

エントリーポイントとなる、index.js を見ていきます。

index.js

import 'react-app-polyfill/ie11';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { unregister } from './registerServiceWorker';

// window オブジェクトにrender${name} の命名規則でマイクロフロントエンドレンダリング用のメソッドを作成する
window.renderBrowse = (containerId, history) => {
  ReactDOM.render(
    <App history={history} />,
    document.getElementById(containerId),
  );
  unregister();
};

// こっちはunmount 用
window.unmountBrowse = containerId => {
  ReactDOM.unmountComponentAtNode(document.getElementById(containerId));
};

window オブジェクトに、renderBrowse メソッドを追加していますね。 これは先ほど紹介したMicroFrontend クラスから参照するために、用意しているもののようです。

その他は、特段取り上げるべきところもない、一般的なReact プロジェクトになっていそうです。

App.js

import React from 'react';
import { Router } from 'react-router-dom';
import { createBrowserHistory } from 'history';
import styled from 'styled-components';
import Loading from './Loading';
import Filters from './Filters';
import RestaurantList from './RestaurantList';

const MainColumn = styled.div`
  max-width: 1150px;
  margin: 0 auto;
`;

const defaultFilters = {
  nameFilter: '',
  priceRangeFilter: {
    $: false,
    $$: false,
    $$$: false,
    $$$$: false,
  },
};

const defaultHistory = createBrowserHistory();

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      restaurants: [],
      loading: true,
      error: false,
      ...defaultFilters,
    };
  }

  componentDidMount() {
    const host = process.env.REACT_APP_CONTENT_HOST;
    fetch(`${host}/restaurants.json`)
      .then(result => result.json())
      .then(restaurants => {
        this.setState({
          restaurants: restaurants.map(restaurant => ({
            ...restaurant,
            imageSrc: `${host}${restaurant.imageSrc}`,
          })),
          loading: false,
        });
      })
      .catch(() => {
        this.setState({ loading: false, error: true });
      });
  }

  setNameFilter = value => this.setState({ nameFilter: value });

  setPriceRangeFilter = range => checked => {
    this.setState(({ priceRangeFilter }) => ({
      priceRangeFilter: {
        ...priceRangeFilter,
        [range]: checked,
      },
    }));
  };

  resetAllFilters = () => this.setState(defaultFilters);

  render() {
    const {
      restaurants,
      priceRangeFilter,
      nameFilter,
      loading,
      error,
    } = this.state;

    if (loading) {
      return <Loading />;
    }

    if (error) {
      return (
        <MainColumn>
          Sorry, but the restaurant list is unavailable right now
        </MainColumn>
      );
    }

    return (
      <Router history={this.props.history || defaultHistory}>
        <MainColumn>
          <Filters
            name={nameFilter}
            priceRange={priceRangeFilter}
            setNameFilter={this.setNameFilter}
            setPriceRangeFilter={this.setPriceRangeFilter}
            resetAll={this.resetAllFilters}
          />
          <RestaurantList
            restaurants={restaurants}
            priceRangeFilter={priceRangeFilter}
            nameFilter={nameFilter}
          />
        </MainColumn>
      </Router>
    );
  }
}

export default App;

アプリケーションで使用するデータについては、JSON 形式で用意されたモックデータになっているようです。 実際には、マイクロフロントエンド単位でカプセル化されたテーブル設計をして、マイクロフロントエンド間のデータのやりとりはEvent を使うなど、一般的なマイクロサービスの手法を用いられることが多いようです。

今回は、簡単にReact の実装部分を見ていきました。 その他のリポジトリ実装や、より詳細な内容についてはこちらで紹介されていますので、興味があれば読んでみてください。

終わりに

本日は、マイクロフロントエンドについて、背景、メリット・デメリット、実現方法をまとめました。

この内容で、マイクロフロントエンドについて知らなかった方も、少しでもイメージ出来るようになっていただけたら幸いです。

自分は、マイクロフロントエンドがメリットを最大限発揮するためには、規模やスキルなどの前提条件もあり、なかなか実現に至るのは難しいような印象を受けましたが、大規模なサービスを作るときの一つの選択肢として、考えておくと良いかもしれませんね。

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

参考