はじめに

こんにちは。西日本テクノロジー&イノベーション室の齊藤です。

私の所属する西日本テクノロジー&イノベーション室では、2020年9月末に「SPA + REST API構成のサービス開発リファレンス(以下サービス開発リファレンス)」を公開しました。こちらは、シングルページアプリケーション(以下SPA)とREST APIから構成されるWebアプリケーションを開発する際に活用して頂けるコンテンツになっています。

前回のブログでは、サービス開発リファレンスの方式設計ガイドをベースにして、方式設計書の作成方法をご紹介しました。

今回は、SPA + REST API構成はフロントエンドとバックエンドを疎結合にできる点について解説します。
その例として、「Vue.js」を使ってフロントエンドを実装し、前回までに実装したバックエンドとつないでみます。

  1. サービス開発リファレンスを使ってWebアプリケーションを作成してみよう<導入編>
  2. サービス開発リファレンスを使って1画面作成してみよう
  3. サービス開発リファレンスを使ってREST APIを1つ作成してみよう
  4. サービス開発リファレンスを使ってバリデーションしてみよう
  5. サービス開発リファレンスを使ってファイルダウンロードを実装してみよう
  6. サービス開発リファレンスを使ってCSRF対策をしてみよう
  7. サービス開発リファレンスの方式設計ガイドを使ってみよう
  8. バックエンドはサービス開発リファレンスを使って、フロントエンドは自前で作成してみた(←今回の記事)

    事前準備

    1~6までの記事でご紹介した作業の完成状態をベースに作成しますので、こちらの作業がまだの方は実施してみてください。

    バックエンド

    サーバの起動

    バックエンドはそのままの状態で使用します。

    以下コマンドでまずデータベースを起動します。

    $ docker-compose up -d

    次のようにdocker-compose psコマンドを実行してStateがUpになっていれば起動成功です。

    $ docker-compose ps
    Name                 Command                        State     Ports                     
    -----------------------------------------------------------------------------------------------------------------
    example_postgres_1   docker-entrypoint.sh postgres    Up      0.0.0.0:5432->5432/tcp  
    example_redis_1      docker-entrypoint.sh redis ...   Up      0.0.0.0:6379->6379/tcp

    次に、backendディレクトリで以下コマンドを実行してサーバを起動しておきましょう。

    $ mvn jetty:run

    フロントエンド

    ライブラリのインストール

    自前で作成するフロントエンドのライブラリには「Vue.js」を使用します。
    Vue.jsには、簡単にプロジェクトを作成できる「Vue CLI」がありますので、こちらを使用します。

    以下コマンドを実行してください。
    ※ backendディレクトリにいる場合は、1つ上の階層へ移動した上で実行してください。

    $ npm install -g @vue/cli
    $ vue create frontend-vue

     ? Please pick a preset: (Use arrow keys) の質問では Manually select features を選び TypeScript を選択してください。
    その他の質問はデフォルトのままでも、お好きにカスタマイズいただいても構いません。

    これでライブラリのインストールは完了です。
    以下コマンドで起動してみましょう。

    $ cd frontend-vue
    $ npm run serve

    無事起動したら、以下URLへアクセスしてみましょう。
    Vue.jsの画面が表示されていれば成功です。

    http://localhost:8080/

    Todo画面の実装

    ではさっそく、Todo画面を実装します。
    まずはタスク(Todo)をソースコードにハードコーディングして画面を作成しましょう。

    以下のようなTodoコンポーネントを作成します。

    frontend-vue/src/components/Todo.vue

    <template>
      <div class="content">
        <ul class="list">
          <li class="item">
            <div class="todo">
              <span>サンプル1</span>
            </div>
          </li>
          <li class="item">
            <div class="todo">
              <span>サンプル2</span>
            </div>
          </li>
          <li class="item">
            <div class="todo">
              <span>サンプル3</span>
            </div>
          </li>
        </ul>
      </div>
    </template>
    
    <script lang="ts">
      import { Component, Vue } from 'vue-property-decorator';
    
      @Component
      export default class Todo extends Vue {}
    </script>
    
    <style scoped>
      .content {
        margin-top: 10px;
        width: 40%;
        padding: 0 30%;
      }
      .list {
        list-style: none;
        padding: 0;
        margin: 20px 0;
      }
      .item {
        padding: 15px 10px;
        background: whitesmoke;
        border: solid 1px lightgray;
        margin-bottom: 10px;
      }
      .todo {
        margin-left: 10px;
        text-align: left;
      }
    </style>

    その次に、App.vue を以下のように書き換え、さきほど作成したTodoコンポーネントを呼び出します。

    frontend-vue/src/App.vue

    <template>
      <Todo />
    </template>
    
    <script lang="ts">
    import { Component, Vue } from 'vue-property-decorator';
    import Todo from './components/Todo.vue';
    
    @Component({
      components: {
        Todo,
      },
    })
    export default class App extends Vue {}
    </script>
    
    <style>
      body {
        margin: 0;
        background-color: #FFFFFF;
      }
    </style>

    動作確認

    ここまで実装できたら、frontend-vueディレクトリで以下コマンドを実行して、フロントエンドアプリを起動しましょう。

    $ npm run serve

    起動したら以下URLを開いてみましょう。
    3つのサンプルのTodoが表示されているはずです。

    http://localhost:8080

    バックエンドとのつなぎ込み

    次に、バックエンドからTodoを取得して画面に表示するように変更します。

    起動ポートの変更

    前回のブログまでに使用していたフロントエンドのポートと合わせるために、起動ポートを変えておきます。
    以下ファイルを作成してください。

    frontend-vue/vue.config.js

    module.exports = {
      devServer: {
        port: 3000,
      },
    };

    これで今後フロントエンドアプリを起動した場合のポートが 3000 になります。

    環境変数の定義

    バックエンドのURLを設定するために環境変数を定義します。
    以下ファイルを作成してください。

    frontend-vue/.env.development

    VUE_APP_BACKEND_BASE_URL=http://localhost:9080

    バックエンド呼び出しのためのコンポーネントの作成

    サービス開発リファレンスでも使用していたバックエンド呼び出しのためのコンポーネントを2つ作成します。
    こちらはサービス開発リファレンスを参考に作成します。

    frontend-vue/src/framework/backend/RestClient.ts

    RestClient.ts(長いため折りたたんでいます)
    const baseUrl: string = process.env.VUE_APP_BACKEND_BASE_URL!.toString();
    
    type Serializer = (body: any) => string;
    
    type ErrorHandler = (error: Error) => Promise<Response>;
    
    const defaultSerializer: Serializer = body => JSON.stringify(body);
    
    const defaultErrorHandler: ErrorHandler = error => {
      console.error(error);
      return Promise.reject(error);
    };
    
    /**
     * Fetch APIへ設定するパラメーターは以下の通り。
     * CORSを使用した通信を行うため mode には cors を設定する。
     * また、Cookieを送信するため credentials には include を設定する。
     * いずれも個々の処理で設定するのではなく、共通部品で設定する。
     * 参考情報: WindowOrWorkerGlobalScope.fetch()(https://developer.mozilla.org/ja/docs/Web/API/WindowOrWorkerGlobalScope/fetch)
     */
    const defaultInit: RequestInit = {
      headers: {
        'content-type': 'application/json',
        'Accept': 'application/json',
      },
      redirect: 'manual',
      mode: 'cors',
      credentials: 'include',
    };
    
    function defaultCalculateInterval(executionCount: number): number {
      // 切り捨て指数型バックオフでリトライのインターバルを算出する
      // https://cloud.google.com/storage/docs/exponential-backoff?hl=ja
      const maxBackoff = 32000; //32秒
      return Math.min(Math.floor((Math.pow(2, executionCount) + Math.random()) * 1000), maxBackoff);
    }
    
    const updatingMethod = ['POST', 'PUT', 'DELETE', 'PATCH'];
    
    /**
     * REST APIとの通信には[Fetch API](https://developer.mozilla.org/ja/docs/Web/API/Fetch_API)を用いる。
     * 一部のリクエストヘッダー(Content-Type、Accept、CSRFトークン)やリクエストボディのシリアライズ方式(JavaScriptのオブジェクトを
     * JSONの文字列へシリアライズする)など、共通化できる要素があるためFetch APIをそのまま使用するのでなくラップした共通部品を用意する。
     *
     * // GETリクエストのコード例
     * restClient.get('/api/channels').then(response => ...);
     *
     * // POSTリクエストのコード例
     * restClient.post('/api/login', { mailAddress, password }).then(response => ...);
     *
     * // PUTリクエストのコード例
     * restClient.put('/api/settings/password', { password, newPassword }).then(response => ...);
     *
     * // DELETEリクエストのコード例
     * restClient.delete(`/api/channels/${channelId}`).then(response => ...);
     *
     * いずれの操作も内部ではFetch APIを使用しておりPromise#Response(https://developer.mozilla.org/ja/docs/Web/API/Response)が返却される。
     */
    export class RestClient {
    
      csrfTokenHeaderName = '';
      csrfTokenValue = '';
    
      constructor(
        private init = defaultInit,
        private serializer = defaultSerializer,
        private errorHandler = defaultErrorHandler,
        private maxRetries = 5,
        private calculateInterval = defaultCalculateInterval) {
      }
    
      private doFetch(input: string, method: string, body?: any): Promise<Response> {
        const init: RequestInit = { ...this.init, method };
        init.headers = { ...init.headers };
        if (body) {
          if (body instanceof FormData) {
            init.body = body;
            delete (init.headers as any)['content-type'];
          } else {
            init.body = this.serializer(body);
          }
        } else {
          delete (init.headers as any)['content-type'];
        }
        if (updatingMethod.includes(method) && this.csrfTokenHeaderName && this.csrfTokenValue) {
          init.headers = { ...init.headers, [this.csrfTokenHeaderName]: this.csrfTokenValue };
        }
        return this.fetchWithRetry(baseUrl + input, init, 0)
          .then(response => {
            if (500 <= response.status && response.status <= 599) {
              return Promise.reject();
            }
            return response;
          })
          .catch(error => {
            return this.errorHandler(error);
          });
      }
    
      private async fetchWithRetry(input: string, init: RequestInit, executionCount: number): Promise<Response> {
        const response = await fetch(input, init);
        if ([500, 503].indexOf(response.status) > -1 && executionCount < this.maxRetries) {
          const retryInterval = this.calculateInterval(executionCount);
          return new Promise(resolve => {
            setTimeout(() => {
              const response = this.fetchWithRetry(input, init, executionCount + 1);
              resolve(response);
            }, retryInterval);
          });
        }
        return response;
      }
    
      get(input: string): Promise<Response> {
        return this.doFetch(input, 'GET');
      }
    
      post(input: string, body?: any): Promise<Response> {
        return this.doFetch(input, 'POST', body);
      }
    
      put(input: string, body: any): Promise<Response> {
        return this.doFetch(input, 'PUT', body);
      }
    
      delete(input: string, body?: any): Promise<Response> {
        return this.doFetch(input, 'DELETE', body);
      }
    }

    frontend-vue/src/framework/backend/BackendService.ts

    import { RestClient } from './RestClient';
    
    const restClient = new RestClient();
    
    const getTodos = async () => {
      console.log('call service of getTodos');
    
      const response = await restClient.get('/api/todos');
      console.log(response);
      return response.json();
    };
    
    export default {
      getTodos,
    };

    Todoコンポーネントの修正

    さきほど作成した BackendService を使用して、バックエンドからTodoを取得します。
    以下のようにTodoコンポーネントを修正してください。

    frontend-vue/src/components/Todo.vue

    <template>
      <div class="content">
        <ul class="list">
          <li class="item" v-for="todo in todos" :key="todo.id">
            <div class="todo">
              <span>{{todo}}</span>
            </div>
          </li>
        </ul>
      </div>
    </template>
    
    <script lang="ts">
      import { Component, Vue } from 'vue-property-decorator';
      import BackendService from "@/framework/backend/BackendService";
    
      export type TodoType = {
        id: number;
        text: string;
      }
    
      @Component
      export default class Todo extends Vue {
        todos: TodoType[] = [];
        created(): void {
          BackendService.getTodos()
              .then(response => {
                console.log(response);
                this.todos = response.map((todo: TodoType) => todo.text);
              });
        }
      }
    </script>
    
    <style scoped>
      .content {
        margin-top: 10px;
        width: 40%;
        padding: 0 30%;
      }
      .list {
        list-style: none;
        padding: 0;
        margin: 20px 0;
      }
      .item {
        padding: 15px 10px;
        background: whitesmoke;
        border: solid 1px lightgray;
        margin-bottom: 10px;
      }
      .todo {
        margin-left: 10px;
        text-align: left;
      }
    </style>

    これでバックエンドとのつなぎ込みの実装は完了です。

    「サービス開発リファレンスを使ってREST APIを1つ作成してみよう」のブログでご紹介した、バックエンドを呼び出す部分のコードを今回のものと見比べてみてください。
    React版とあまり変わっていないのがわかるはずです。

    動作確認

    ここまで実装できたら、frontend-vueディレクトリで以下コマンドを実行して、フロントエンドアプリを起動しましょう。

    $ npm run serve

    起動したら以下URLを開いてみましょう。
    Todoの内容がバックエンドから取得した内容に変わっています。

    http://localhost:3000

    まとめ

    このように、サービス開発リファレンスで示しているReact以外のライブラリを使用してフロントエンドを実装できました。
    SPA + REST API構成では、フロントエンドとバックエンドが疎結合になるという点についてご紹介しました。

    今回は、フロントエンドは自前で作成して、バックエンドにサービス開発リファレンスを使いました。
    次回は、バックエンドは自前で作成して、フロントエンドにサービス開発リファレンスを使う例を紹介します。


    本コンテンツはクリエイティブコモンズ(Creative Commons) 4.0 の「表示—継承」に準拠しています。