Micro Frontendsというフロントエンドマイクロサービス化をポートフォリオに導入してみた

今回[ポートフォリオの作成](https://github.com/rui-watanabe/k8s-original-covid-app)で、Micro Frontendsという技術を採用したので、その概要や方法についてまとめていきたいと思います。

 
## Micro Frontendsとは

Micro Frontendsとは、バックエンドのMicro Servicesの考え方をフロントエンドにも取り入れた考え方です。

複雑で規模の大きいアプリケーションでは、バックエンド側だけではなくフロントエンド側もモノリスで複雑性が増して管理がしにくくなるといった問題がありますが、Micro Frontendsの考え方はその問題を解決する手段の一つです。


## Micro Frontendsのメリット
Micro Frontendsのメリットとしては、各フロントエンドのサービスごとに技術の選定が行え、疎結合なので耐障害性に強く、特定のミッションを達成するためのタスクの明確性をはかれるなどの恩恵を受けることができます。

 

## Micro Frontendのデメリット

Micro Frontendsのデメリットとしては、Gitのリポジトリーが増えたり、技術スタックがチームごとに異なっていたり、日本語文献が少なかったり、CSSの影響範囲の管理をしなければいけなかったりなどのことが挙げられます。

そのため、ある程度規模があるサービスでフロントエンドが複雑な状況であったり、チームごとにフロントエンドの技術スタックを分けたいという考えがあったり、サービスをまたいで共通したフロントエンドのコンポーネントを使いまわしたいという要望があったり、などの場合に採用の検討がされるのではないかと思います。

 

## Micro Frontendsの実現方法

Micro Frontendsを提唱した、[ThoughtWorks社の記事](https://martinfowler.com/articles/micro-frontends.html)では、実現方法として以下の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を採用しました。

 

この方式では各プロダクトがSPAで動作するように作られている前提で、各プロダクトを Micro Frontends のコンテナ側で読み込み、各プロダクトをパスの変更のみで提供するという動作するといった方法になります。


## ポートフォリオの構成

ポートフォリオは世界のコロナ感染者と、日本の医療逼迫状況をグラフで表示するアプリケーションとなっており、React(Function Component)とTypeScriptで、Micro Frontendsを実現しています。

![demo](https://gyazo.com/764f9a5fd630c302992a0d80d757f331/raw)

Micro Frontendsの表示を読み込むコンテナ側で、表示を作成するWorldコンポーネントとJapanコンポーネントはプロダクト側となっています。

（ちなみにせっかくマイクロフロントエンドにしてるんで、Kubernetesクラスターでフロントエンドとバックエンドの各マイクロサービスをコンテナ管理しったり、バックエンドはGoで書いたり、ArgoCDを取り入れたりしてます）
 
## プロダクト側の設定

表示を作成するコンポーネントであるプロダクト側の設定を行います。

### パッケージインストール

まず必要なパッケージをインストールします。

プロダクト側では、カレントパスの取得と設定を行うhistory、ルーティングを行うreact-router-dom、webpack環境のejectをさせないためのreact-app-rewiredが必要となります。

```
# yarn add react-app-rewired history react-router-dom
```

### 描画とアンマウントの設定

外部からコンポーネントの描画とアンマウントを行えるように、windowsオブジェクトの設定をindex.tsxで定義します。

実際に[Worldコンポーネント](https://github.com/rui-watanabe/original-covid-app-world)で記載しているコードは以下のようになります。

``` index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import * as H from 'history';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { Provider } from 'react-redux';
import { store } from './app/store';

declare global {
  interface Window {
    renderWorld: (containerId: string, history: H.History) => void;
    unmountWorld: (containerId: string) => void;
  }
}

window.renderWorld = (containerId, history) => {
  ReactDOM.render(
    <React.StrictMode>
      <Provider store={store}>
        <App history={history} />
      </Provider>
    </React.StrictMode>,
    document.getElementById(containerId)
  );
  serviceWorker.unregister();
};

window.unmountWorld = (containerId) => {
  ReactDOM.unmountComponentAtNode(
    document.getElementById(containerId) as HTMLElement
  );
};
```

プロダクト側のコンポーネントを描画するために、renderWorldというwindowsオブジェクトのメソッドを新たに追加しています。

描画する場所については、引数としてわたってきたcontaineridのid名のエレメントに展開されます。

それとは反対にアンマウントする際は、umountWorldというwindowsオブジェクトのメソッドを追記し、id名のエレメントを削除します。
    

### Webpackの設定

Run-time integration via JavaScriptでの読み込むJavaScriptファイルは、Sigle Chunkでビルド後に一つのJavaScriptファイルにまとめる必要があります。

そのためプロダクト側のWebpackのオーバーライドの設定ファイルを作成します。


```config-override.js

module.exports = {​​​​​​​​​
  webpack: (config, env) => {​​​​​​​​​
    config.optimization.runtimeChunk = false;
    config.optimization.splitChunks = {​​​​​​​​​
      cacheGroups: {​​​​​​​​​
        default: false,
      }​​​​​​​​​,
    }​​​​​​​​​;

    config.output.filename = "static/js/[name].js";
    config.plugins[5].options.filename = "static/css/[name].css";
    config.plugins[5].options.moduleFilename = () => "static/css/main.css";
    return config;
  }​​​​​​​​​,
}​​​​​​​​​;
```



### ビルドとサーバー起動時の設定

ビルドとサーバー起動時について、react-app-rewiredで行うようにする設定を、package.jsonに記載します。（create-react-appではreact-scriptsが採用されているが、JavaScriptが複数ファイルに分けられているので、react-app-rewiredでのSigle Chunkの設定が必要）。


```package.json
(snip)
  "scripts": {​​​​​​​​​
    "start": "PORT=3001 react-app-rewired start",
    "build": "react-app-rewired build",
  }​​​​​​​​​
(snip)
```

以上の設定でサーバーを3001番ポートで起動して、asset-manifest.jsonにアクセスしてみると、Webpackでオーバーライドしているので以下のような表示になると思います。


```asset-manifest.json
(snip)
"entrypoints": [
  "static/css/main.css",
  "static/js/main.js"
]
(snip)
```
上記のパスをコンテナ側で読み込みます。


### CORSの有効設定

異なるオリジン間でのアクセスを必要とするため、Access-Control-Allow-Originの許可設定を行います。（制限が必要な場合は適宜書き換える）

``` settProxy.js
module.exports = (app) => {
  app.use((req, res, next) => {
    res.header("Access-Control-Allow-Origin", "*");
    next();
  });
}
````


## コンテナ側の設定

プロダクト側のファイルを読み込み、コンテナ側でコンポーネントの表示を行う設定を行います。

### パッケージインストール

まず必要なパッケージをインストールします。

コンテナ側では、カレントパスの取得と設定を行うhistory、ルーティングを行うreact-router-domが必要となります。

```
# yarn add history react-router-dom
```
    

### プロダクト側のコンポーネントの読込設定

プロダクト側のコンポーネントを読み込むために、以下のようなMicroFrontend.tsxを作成します。

```MicroFrontend.tsx

import React, {​​​​​​​​​ useEffect }​​​​​​​​​ from 'react';
import * as H from 'history';
import axios from 'axios';

type MicroFrontendType = {​​​​​​​​​
  name: string;
  host: string;
  history: H.History;
}​​​​​​​​​;

declare global {​​​​​​​​​
  interface Window {​​​​​​​​​
    renderWorld: (containerId: string, history: H.History) => void;
    unmountWorld: (containerId: string) => void;
    renderJapan: (containerId: string, history: H.History) => void;
    unmountJapan: (containerId: string) => void;
     [key: string]: (containerId: string, history?: H.History) => void;
  }​​​​​​​​​
}​​​​​​​​​

const MicroFrontend: {​​​​​​​​​
  ({​​​​​​​​​ name, host, history }​​​​​​​​​: MicroFrontendType): JSX.Element;
}​​​​​​​​​ = ({​​​​​​​​​ name, host, history }​​​​​​​​​: MicroFrontendType) => {​​​​​​​​​
  useEffect(() => {​​​​​​​​​
    const scriptId = `micro-frontend-script-${​​​​​​​​​name}​​​​​​​​​`;
    const renderMicroFrontend = () => {​​​​​​​​​
      if (window[`render${​​​​​​​​​name}​​​​​​​​​`]) {​​​​​​​​​
        window[`render${​​​​​​​​​name}​​​​​​​​​`](`${​​​​​​​​​name}​​​​​​​​​-container`, history);
      }​​​​​​​​​
    }​​​​​​​​​;

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

    (async () => {​​​​​​​​​
      const {​​​​​​​​​ data }​​​​​​​​​ = await axios.get(`${​​​​​​​​​host}​​​​​​​​​/asset-manifest.json`);
      const script = document.createElement('script');
      script.id = scriptId;
      script.crossOrigin = '';
      script.src = `${​​​​​​​​​host}​​​​​​​​​${​​​​​​​​​data.files['main.js']}​​​​​​​​​`;
      script.onload = () => {​​​​​​​​​
        renderMicroFrontend();
      }​​​​​​​​​;
      document.head.appendChild(script);
      const link = document.createElement('link');
      link.rel = 'stylesheet';
      link.crossOrigin = '';
      link.type = 'text/css';
      link.href = `${​​​​​​​​​host}​​​​​​​​​${​​​​​​​​​data.files['main.css']}​​​​​​​​​`;
      link.onload = () => {​​​​​​​​​
        renderMicroFrontend();
      }​​​​​​​​​;
      document.head.appendChild(link);
    }​​​​​​​​​)();

    return () => {​​​​​​​​​
      if (window[`unmount${​​​​​​​​​name}​​​​​​​​​`]) {​​​​​​​​​
        window[`unmount${​​​​​​​​​name}​​​​​​​​​`](`${​​​​​​​​​name}​​​​​​​​​-container`);
      }​​​​​​​​​
    }​​​​​​​​​;
  }​​​​​​​​​);

  return <main id={​​​​​​​​​`${​​​​​​​​​name}​​​​​​​​​-container`}​​​​​​​​​ />;
}​​​​​​​​​;

export default MicroFrontend;

```



以上の設定では、rendeMicrofrontendでプロダクト側のasset-manifest.jsonで出力した情報を読み込んでコンポーネントを描画をしています。

パスを切り替えた場合などは、id名が設定されている既存のコンポーネントはアンマウントされ、新しいコンポーネントが描画されます。

そしてこのMicroFrontendを呼びだす、コンポーネントの設定を行います。

```World.tsx
import React from 'react';
import {​​​​​​​​​ HistoryType }​​​​​​​​​ from '../../../App';
import MicroFrontend from '../MicroFrontend';

const World = ({​​​​​​​​​ history }​​​​​​​​​: HistoryType): JSX.Element => {​​​​​​​​​
  return (
    <>
      <MicroFrontend
        history={​​​​​​​​​history}​​​​​​​​​
        host="localhost:3001"
        name="World"
      />;
    </>
  );
}​​​​​​​​​;

export default World;
```

さらに該当するパスにアクセスした際のルーティングの設定を、App.tsxで定義します。

BrowserRouterは、historyAPIを外部から渡すことができるパッケージになります。
 
```App.tsx

import React from 'react';
import * as H from 'history';
import { BrowserRouter, Switch, Route } from 'react-router-dom';
import Japan from './features/covid/Japan/Japan';
import World from './features/covid/World/World';
import NotFound from './features/covid/NotFound/NotFound';

export type HistoryType = {
  history: H.History;
};

const App: React.FC = () => {
  return (
      <BrowserRouter>
        <Switch>
          <Route exact path="/world" component={World} />
          <Route exact path="/japan" component={Japan} />
          <Route component={NotFound} />
        </Switch>
      </BrowserRouter>
  );
};

export default App;
```

以上の設定で、Run-time integration via JavaScript方式のMicro Frontendsの設定は完了です。

history APIの根本的な理解が必要だったり、日本語文献がなかったり、React+TypeScriptで実装されている例を見つけられなかったりで苦労し、現状複雑になってしまっていますが今後ブラッシュアップしていきたいと思います。

 
## 参考URL
 
https://blog.bitsrc.io/how-to-develop-microfrontends-using-react-step-by-step-guide-47ebb479cacd


https://www.udemy.com/course/covid-19-react-live/


https://tech.mti.co.jp/entry/2021/04/12/112833

https://martinfowler.com/articles/micro-frontends.html

https://micro-frontends-japanese.org/

https://note.com/dd_techblog/n/nfe70dfed34c4

https://qiita.com/kei37/items/fb1a9ef7ca2c33e2cc3d

https://qiita.com/kei37/items/a9e482e2f37d269cb228

https://qiita.com/asmasa/items/c4da2559b2fb713aaad3