はじめに

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

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

前回のブログでは、バリデーションの実装をご紹介しました。今回は、登録したTodoをCSVファイルとしてダウンロードする機能について実装していきます。

  1. サービス開発リファレンスを使ってWebアプリケーションを作成してみよう<導入編>
  2. サービス開発リファレンスを使って1画面作成してみよう
  3. サービス開発リファレンスを使ってREST APIを1つ作成してみよう
  4. サービス開発リファレンスを使ってバリデーションしてみよう
  5. サービス開発リファレンスを使ってファイルダウンロードを実装してみよう(←今回の記事)

    事前準備

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

    ファイルダウンロードの方針

    REST API設計の検討

    単一リクエストでファイルダウンロードを行おうとすると、今まで実装したREST APIの呼び出し方とは異なり、a要素によるリンクやサブミットによるフォーム送信でリクエストを送信することになります。そうした場合、サーバサイドでのファイル生成処理などでもしエラーが発生すると、エラーを通知するためにはサーバサイドでエラーページを生成して返却する必要があります。そのため、今まで実装したREST APIを呼び出す場合とエラーハンドリングの方法が異なることになります。

    そのような点を考慮して、ここでは単一リクエストで全ての処理を行わず、リクエストを大きく次の二種類に分割して考えることにします。

    1. ファイルを生成するためのリクエスト
    2. 生成されているファイルをダウンロードするためのリクエスト

    ファイル生成ではデータ取得やファイル出力が必要であるため、ダウンロード処理と比較してシステム負荷が高く、エラーが発生する可能性も高くなります。この処理を実際にダウンロードするためのリクエストとは分けることで、ファイルを生成するためのリクエストは今まで実装したREST APIと同じように呼び出すことができます。それにより、エラーハンドリングについても同じ考え方で対応しやすくなります。

    ユーザーインタフェースの検討

    REST APIは前述の方針に沿って考えるとして、ファイルダウンロードのユーザーインタフェースをどうするかによって、リクエストをさらに分割することを考えます。ダウンロードするためのボタンを画面に用意するとして、ユーザーインタフェースとしては次のどちらかが考えられます。

    • ユーザーがボタン等をクリックしてファイル生成とファイルダウンロードを同時に行う「同期方式」
    • ユーザーがボタン等をクリックしてファイル生成の要求だけ行い、ダウンロード準備が完了したら通知されるリンクやボタン等をクリックしてダウンロードを行う「非同期方式」

    SPA + REST API構成のサービス開発リファレンス」のコンテンツのひとつである「方式設計ガイド」でもファイルダウンロードについて記載があります。そこでは「同期方式」について記載しており、同じくコンテンツのひとつである「example-chat」では「同期方式」でファイルダウンロードを実装しています。

    同期方式のポイント

    同期方式ではユーザーインタフェースや設計もシンプルになり、ユーザーの操作も分かりやすく簡単になります。ただし、大量のデータを扱ったりしてファイル生成処理に時間が掛かる場合、クリックしてからファイルダウンロードが完了するまで長時間かかってしまったり、途中でタイムアウトによりエラーになることもあります。

    非同期方式のポイント

    非同期方式ではユーザーが複数の操作をする必要があるため、同期方式と比較するとユーザーインタフェースは複雑になります。また、ユーザーへの通知方法やファイル公開方法、ファイル削除タイミングなど考える要素が多くなるため設計難易度も高くなります。ただ、ファイル生成処理等のシステム負荷の高い処理をバックグラウンドで行うことができるため、同期方式で問題になる長時間のファイルダウンロード待ちやタイムアウトを避けることができます。他にも、バックグラウンドで処理するため大量のリクエストが想定される場合でも処理量を制御しやすかったりと、複雑であるかわりに柔軟な設計が可能になります。

    今回の実装方針

    方式設計ガイドやサービス開発リファレンスを参考にしやすいという点を考慮し、まず「同期方式」でファイルダウンロードを実装していきます。同期方式として実装するため、ダウンロードするためのリクエストを次のように分割して実装します。

    1. Fetch APIでダウンロード要求のリクエストを送信し、ファイルの生成が完了したらファイルキーを返却してもらう
    2. Fetch APIでファイルキーからダウンロードするためのリクエストを送信し、ファイルデータを返却してもらう
    3. File APIを使用してファイルデータをBlobオブジェクトで扱い、Blogオブジェクトを表すURIへのリンクからファイルをダウンロードする

    また、この実装の一部を用いて「非同期方式」として実装することもできますので、参考までに非同期方式も簡易的に実装していきます。最初のリクエストは同じように呼び出し、その後はダウンロード用のリンクを生成してそれをユーザーにクリックしてもらうように実装します。

    1. Fetch APIでファイルダウンロード要求のリクエストを送信し、ファイルの生成が完了したらファイルキーを返却してもらう
    2. (ファイルキーが返却されたら、ファイルキーからダウンロードするためのリンクを生成し、画面に表示する)
    3. リンクをクリックして、ファイルをダウンロードする

    非同期方式ではユーザーへの通知方法やリンク先の公開方法など検討すべき点がいくつかありますが、ここでは同期方式の実装を流用できる範囲で簡易的に実装します。

    同期方式でファイルダウンロードする

    バックエンド

    ファイルを作成してダウンロードするために、次のREST APIを作成します。

    • 登録されているTodoからファイルを生成して、ファイルキーを返すREST API
    • ファイルキーを受け取り、ファイルキーに対応するファイルを返すREST API

    ファイルダウンロード機能の実装

    FileDownloadActionクラスを作成し、ファイルを生成するREST APIと、ファイルを返すREST APIを実装します。

    backend/src/main/java/com/example/presentation/restapi/todo/FileDownloadAction.java

    package com.example.presentation.restapi.todo;
    
    import com.example.infrastructure.persistence.entity.TodoEntity;
    import nablarch.common.dao.EntityList;
    import nablarch.common.dao.UniversalDao;
    import nablarch.core.repository.di.config.externalize.annotation.SystemRepositoryComponent;
    import nablarch.fw.web.HttpRequest;
    import nablarch.fw.web.HttpResponse;
    
    import javax.ws.rs.GET;
    import javax.ws.rs.POST;
    import javax.ws.rs.Path;
    import javax.ws.rs.Produces;
    import javax.ws.rs.core.MediaType;
    import java.nio.file.Files;
    import java.nio.file.Paths;
    import java.util.List;
    import java.util.UUID;
    import java.util.stream.Collectors;
    
    @SystemRepositoryComponent
    @Path("/files")
    public class FileDownloadAction {
    
        @POST
        @Produces(MediaType.APPLICATION_JSON)
        public CreateResponse create() throws Exception {
            EntityList<TodoEntity> todoEntities = UniversalDao.findAll(TodoEntity.class);
    
            // サンプルのためCSV変換処理は簡易的に実装しているが、実案件ではライブラリの利用を検討すること
            List<String> lines = todoEntities.stream()
                    .map(entity -> String.join(",", entity.getTodoId().toString(), entity.getText()))
                    .collect(Collectors.toList());
    
            String fileKey = generateFileKey();
            // java.nio.file.Pathとjavax.ws.rs.Pathで単純名が重複するため、完全修飾名で指定している
            java.nio.file.Path filePath = getCsvFilePath(fileKey);
            Files.createFile(filePath);
            Files.write(filePath, lines);
    
            return new CreateResponse(fileKey);
        }
    
        @Path("{fileKey:.+}")
        @GET
        public HttpResponse download(HttpRequest request) throws Exception {
            String fileKey = request.getParam("fileKey")[0];
    
            java.nio.file.Path filePath = getCsvFilePath(fileKey);
            byte[] fileData = Files.readAllBytes(filePath);
    
            HttpResponse response = new HttpResponse();
            response.setContentType("text/csv");
            response.write(fileData);
    
            return response;
        }
        
        private String generateFileKey() {
            return UUID.randomUUID().toString();
        }
    
        private java.nio.file.Path getCsvFilePath(String fileKey) {
            String outputDir = System.getProperty("java.io.tmpdir");
            String fileName = fileKey + ".csv";
            return Paths.get(outputDir, fileName);
        }
    
        public static class CreateResponse {
    
            public String fileKey;
    
            public CreateResponse(String fileKey) {
                this.fileKey = fileKey;
            }
        }
    }

    createメソッドがファイルを生成してファイルキーを返すREST API、download メソッドがファイルキーに対応するファイルを返すREST APIになります。

    ファイルを生成するREST APIでは、ToDoを取得するREST APIと同様にユニバーサルDAOを使用してデータベースからToDoを取得し、それをカンマ区切りにしてファイルに出力しています。ファイルに対応するファイルキーについては重複せず一意になるように生成し、それを返します。

    ファイルを返すREST APIでは、パスパラメータに含まれるファイルキーから対応するファイルを読み込み、text/csvのコンテンツとしてファイルデータを返します。

    サーバの起動

    ここまで実装できたら、サーバを起動してみましょう。 以下コマンドでまずデータベースを起動します。

    $ 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

    動作確認はフロントエンドとあわせて実施します。

    フロントエンド

    Todo画面にファイルダウンロードをするためのボタンを作成し、ファイルダウンロードができるようにします。

    REST APIを呼び出す関数

    REST APIを呼び出すための関数を、frontend/src/example/backend/BackendService.tsに追加します。

    const createFile = async () => {
      Logger.debug('call service of createFile');
    
      const response = await restClient.post('/api/files');
      Logger.debug(response);
      if (response.ok) {
        return response.json();
      }
      throw new Error(`Web API call failed. [ status code: ${response.status} ]`);
    };
    
    const downloadFile = async (fileKey: string) => {
      Logger.debug('call service of downloadFile');
    
      const response = await restClient.get(`/api/files/${fileKey}`);
      Logger.debug(response);
      if (response.ok) {
        return response.blob();
      }
      throw new Error(`Web API call failed. [ status code: ${response.status} ]`);
    };

    コンポーネントからこれらの関数を呼び出せるように、export defaultに追加します。

    export default {
      getHelloWorld,
      getTodos,
      postTodo,
      createFile,
      downloadFile,
    };

    外観の作成

    次にファイルをダウンロードするためのボタンを作成します。

    まず、frontend/src/example/components/pages/Todo.cssに次の定義を追加します。

    .download_button_field {
      text-align: left;
      width: 30%;
      margin-bottom: 20px;
    }
    .download_button_field button {
      height: 35px;
      cursor: pointer;
      line-height: 1;
      font-size: 1rem;
      color: white;
      background-color: black;
      border-radius: 5px;
      padding: 0 15px;
      border: none;
      vertical-align: middle;
    }

    次に、frontend/src/example/components/pages/Todo.tsxにダウンロードするためのフォームを作成します。なお、まだダウンロード処理を実装していないため、onSubmitには何も設定しないままにしています。

    return (
      <div className="content">
    
        ~~~~~
    
        <form className="form">
          <div className="download_button_field">
            <button type="submit">ダウンロード</button>
          </div>
        </form>
      </div>
    );

    ファイルダウンロード処理の実装

    次に、ボタンをクリックした際に実行するファイルダウンロード処理を実装します。

    先ほどBackendService.tsに作成した関数を順に呼び出し、受信したファイルデータを使用してファイルをダウンロードするようにします。

    ファイルデータからファイルをダウンロードするためのフックとしてuseDownloaderが実装されているため、これも使用します。

    useDownloaderでは、方式設計ガイドに記載されている次の処理が実装されています。

    File APIを使用してファイルデータをBlobオブジェクトで扱い、Blogオブジェクトを表すURIへのリンクからファイルをダウンロードする

    const download = useDownloader();
    
    const downloadFile = async (event: React.FormEvent<HTMLFormElement>) => {
      event.preventDefault();
      const { fileKey } = await BackendService.createFile();
      const fileData = await BackendService.downloadFile(fileKey);
      download(fileData, `${Date.now()}.csv`);
    };

    次に、この関数を先ほど作成したFormのonSubmitに設定します。

    return (
      <div className="content">
    
        ~~~~~
    
        <form className="form" onSubmit={downloadFile}>
          <div className="download_button_field">
            <button type="submit">ダウンロード</button>
          </div>
        </form>
      </div>
    );

    ここまで実装すると、frontend/src/example/components/pages/Todo.tsxは次のようになります。

    import React, {useEffect, useState} from 'react';
    import {Logger, stringField, useDownloader, useInput, useValidation} from '../../../framework';
    import { BackendService } from '../../backend';
    import './Todo.css';
    import {ValidationError} from '../basics';
    
    type Todo = {
      id: number
      text: string
    }
    
    const Todo: React.FC = () => {
      const [todos, setTodos] = useState<string[]>([]);
      const [text, textAttributes] = useInput('');
      const { error, handleSubmit } = useValidation<{text: string}>({
        text: stringField()
          .required('やることは必須です。')
          .maxLength(20, 'やることは20字以内で入力してください。'),
      });
    
      useEffect(() => {
        BackendService.getTodos()
          .then(response => {
            Logger.debug(response);
            setTodos(response.map((todo: Todo) => todo.text));
          });
      }, []);
    
      const postTodo = (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        BackendService.postTodo(text)
          .then(() => setTodos([...todos, text]));
      };
    
      const download = useDownloader();
    
      const downloadFile = async (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        const { fileKey } = await BackendService.createFile();
        const fileData = await BackendService.downloadFile(fileKey);
        download(fileData, `${Date.now()}.csv`);
      };
    
      return (
        <div className="content">
          <div className="form_field">
            <form className="form" onSubmit={handleSubmit({ text }, postTodo)}>
              <div className="input_field">
                <input type="text" {...textAttributes} placeholder="やることを入力してください" />
                <ValidationError message={error.text}/>
              </div>
              <div className="button_field">
                <button type="submit">追加</button>
              </div>
            </form>
          </div>
          <ul className="list">
            {todos.map((todo, index) =>
              <li className="item" key={index}>
                <div className="todo">
                  <span>{todo}</span>
                </div>
              </li>
            )}
          </ul>
          <form className="form" onSubmit={downloadFile}>
            <div className="download_button_field">
              <button type="submit">ダウンロード</button>
            </div>
          </form>
        </div>
      );
    };
    
    export default Todo;

    動作確認

    次に、frontendディレクトリで以下コマンドを実行して、フロントエンドアプリを起動しましょう。

    $ npm start

    自動でブラウザが立ち上がり、Top画面が表示されます。

    表示されたら、以下URLのTodo画面を開いて、実際にファイルをダウンロードしてみましょう。

    http://localhost:3000/todo

    次のような画面になっているため、「ダウンロード」ボタンをクリックすると、CSVファイルがダウンロードされることを確認します。

    非同期方式でファイルダウンロードする

    次に、同期方式の実装を流用して、非同期方式でのファイルダウンロードを作成します。

    フロントエンド

    先ほど作成した「ダウンロード」ボタンの下に「ファイル作成」ボタンを作成し、クリックするとさらにその下にダウンロード用のリンクを表示するようにします。

    frontend/src/example/components/pages/Todo.tsxに次の処理を追加します。なお、方針説明のところで記載していたとおり簡易的な実装による紹介であるため、バックエンド接続先はリテラルで記述しています。

    const [downloadUrl, setDownloadUrl] = useState<string>('');
    
    const createFile = async (event: React.FormEvent<HTMLFormElement>) => {
      event.preventDefault();
      const { fileKey } = await BackendService.createFile();
      setDownloadUrl(`http://localhost:9080/api/files/${fileKey}`);
    };

    次に、「ダウンロード」ボタンと同じようにフォームを作成し、stateのdownloadUrlが設定されていればリンクを表示するようにします。

    return (
      <div className="content">
    
        ~~~~~
    
        <form className="form" onSubmit={createFile}>
          <div className="download_button_field">
            <button type="submit">ファイル作成</button>
          </div>
        </form>
        { downloadUrl && <a href={downloadUrl}>ダウンロード</a> }
      </div>
    );

    ここまで実装すると、frontend/src/example/components/pages/Todo.tsxは次のようになります。

    import React, {useEffect, useState} from 'react';
    import {Logger, stringField, useDownloader, useInput, useValidation} from '../../../framework';
    import { BackendService } from '../../backend';
    import './Todo.css';
    import {ValidationError} from '../basics';
    
    type Todo = {
      id: number
      text: string
    }
    
    const Todo: React.FC = () => {
      const [todos, setTodos] = useState<string[]>([]);
      const [text, textAttributes] = useInput('');
      const { error, handleSubmit } = useValidation<{text: string}>({
        text: stringField()
          .required('やることは必須です。')
          .maxLength(20, 'やることは20字以内で入力してください。'),
      });
    
      useEffect(() => {
        BackendService.getTodos()
          .then(response => {
            Logger.debug(response);
            setTodos(response.map((todo: Todo) => todo.text));
          });
      }, []);
    
      const postTodo = (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        BackendService.postTodo(text)
          .then(() => setTodos([...todos, text]));
      };
    
      const download = useDownloader();
    
      const downloadFile = async (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        const { fileKey } = await BackendService.createFile();
        const fileData = await BackendService.downloadFile(fileKey);
        download(fileData, `${Date.now()}.csv`);
      };
    
      const [downloadUrl, setDownloadUrl] = useState<string>('');
    
      const createFile = async (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        const { fileKey } = await BackendService.createFile();
        setDownloadUrl(`http://localhost:9080/api/files/${fileKey}`);
      };
    
      return (
        <div className="content">
          <div className="form_field">
            <form className="form" onSubmit={handleSubmit({ text }, postTodo)}>
              <div className="input_field">
                <input type="text" {...textAttributes} placeholder="やることを入力してください" />
                <ValidationError message={error.text}/>
              </div>
              <div className="button_field">
                <button type="submit">追加</button>
              </div>
            </form>
          </div>
          <ul className="list">
            {todos.map((todo, index) =>
              <li className="item" key={index}>
                <div className="todo">
                  <span>{todo}</span>
                </div>
              </li>
            )}
          </ul>
          <form className="form" onSubmit={downloadFile}>
            <div className="download_button_field">
              <button type="submit">ダウンロード</button>
            </div>
          </form>
          <form className="form" onSubmit={createFile}>
            <div className="download_button_field">
              <button type="submit">ファイル作成</button>
            </div>
          </form>
          { downloadUrl && <a href={downloadUrl}>ダウンロード</a> }
        </div>
      );
    };
    
    export default Todo;

    動作確認

    先ほどフロントエンドアプリは起動していますので、Todo画面のページが自動で更新されます。もしアプリを停止していた場合は、npm startコマンドで再度起動してください。

    「ファイル生成」ボタンが追加されていますので、クリックするとボタンの下にリンクが表示されることを確認します。表示されたリンクをクリックすると、CSVファイルがダウンロードされることを確認します。

    まとめ

    今回は、サービス開発リファレンスを使ってファイルダウンロードを実装しました。 次回はSPAにおけるCSRF対策をご紹介します。


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