はじめに
フロントエンドは Angular、バックエンドが Flask の環境でCSVファイルを出力するときにやったことのメモ。
とはいうものの、やっていることに Angular 要素はほとんどなくて、ほぼ JavaScript ( TypeScript ) と HTML5 で実現している。
更新情報
2021/01/11
- 記事内で扱ったコードを Angular
v11.0.5
で確認しました
作業環境
フロントエンド
環境 | バージョン | 備考 |
---|---|---|
Angular CLI |
|
$ ng --version |
Angular |
|
同上 |
TypeScript | v4.0.2 | 同上 |
Node.js |
|
$ node --version |
npm |
|
$ npm --version |
ng version の結果
$ ng version
_ _ ____ _ ___
/ \ _ __ __ _ _ _| | __ _ _ __ / ___| | |_ _|
/ △ \ | '_ \ / _` | | | | |/ _` | '__| | | | | | |
/ ___ \| | | | (_| | |_| | | (_| | | | |___| |___ | |
/_/ \_\_| |_|\__, |\__,_|_|\__,_|_| \____|_____|___|
|___/
Angular CLI: 11.0.5
Node: 12.18.3
OS: darwin x64
Angular: 11.0.5
... animations, cli, common, compiler, compiler-cli, core, forms
... platform-browser, platform-browser-dynamic, router
Ivy Workspace: Yes
Package Version
---------------------------------------------------------
@angular-devkit/architect 0.1100.5
@angular-devkit/build-angular 0.1100.5
@angular-devkit/core 11.0.5
@angular-devkit/schematics 11.0.5
@schematics/angular 11.0.5
@schematics/update 0.1100.5
rxjs 6.6.0
typescript 4.0.2
バックエンド
バージョン | 備考 | |
---|---|---|
Python | 3.7.2 | $ python --version |
Flask | 1.0.2 | $ flask --version |
前提: CSVファイルの出力にあたって
バックエンドは CSV データとファイル名を返却するに留め、CSVファイルの出力処理そのものは フロントエンド で行った。
このときの 流れは次のとおり。
- フロントエンドからバックエンドへ
- CSV データを返却してもらう REST-API を実行する
- バックエンドからフロントエンドへ
- CSV データとファイル名をプロパティとした JSON を返却する
- フロントエンドで CSV ファイルを出力する
- バックエンドから返却された CSV データを
Blob
型のオブジェクトにする - CSVファイルのファイル名はバックエンドから返却されたファイル名とする
- CSVファイルの文字コードは
UTF-8 BOMあり
とする
- バックエンドから返却された CSV データを
結論から
フロントエンド、バックエンドともに次の処理で実現できた。
フロントエンド
テンプレート
<div>
<button type="button" class="btn btn1" (click)="outputCsv($event)">CSV出力</button>
<a id="csv-donwload"></a>
</div>
コンポーネント
import { Component, OnInit, ElementRef } from '@angular/core';
import { HttpClientService } from '../service/http-client.service';
@Component({
selector: 'app-http-client',
templateUrl: './http-client.component.html',
styleUrls: ['./http-client.component.css']
})
export class HttpClientComponent implements OnInit {
private element: HTMLElement;
/**
* コンストラクタ. HttpClientComponent のインスタンスを生成する
* 自作した HttpClientService を DI する
*
* @param {HttpClientService} httpClientService HTTP通信を担当するサービス
* @param {ElementRef} elementRef DOM参照のためのモジュール
* @memberof HttpClientComponent
*/
constructor(
private httpClientService: HttpClientService,
private elementRef: ElementRef
) {
this.element = this.elementRef.nativeElement;
}
/**
* ライフサイクルメソッド。コンポーネントの初期化で使用する
* 今回はなにもしない
*
* @memberof HttpClientComponent
*/
ngOnInit() {}
public async outputCsv(event: any): Promise<any> {
//-------------------------------------------
// 1. REST-API を実行して CSV データを取得する
//-------------------------------------------
this.httpClientService.getCsv()
.then(
(response: any) => {
const csv = response.csv;
const filename = response.fileName;
//-------------------------------------------
// 2. レスポンスを加工してCSVファイルとURLを作る
//-------------------------------------------
// CSV ファイルは `UTF-8 BOM有り` で出力する
// そうすることで Excel で開いたときに文字化けせずに表示できる
const bom = new Uint8Array([0xEF, 0xBB, 0xBF]);
// CSVファイルを出力するために Blob 型のインスタンスを作る
const blob = new Blob([bom, csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
//-------------------------------------------
// 3. 出力はリンクタグのDOMを取得してそこから行う
//-------------------------------------------
// this.element は `ElementRef.nativeElement` から取得した `HTMLElement`
const link: HTMLAnchorElement = this.element.querySelector('#csv-donwload') as HTMLAnchorElement;
link.href = url;
link.download = filename;
link.click();
}
)
.catch(
(error) => console.log(error)
);
}
}
サービス
import { Injectable } from '@angular/core';
// REST クライアント実装ののためのサービスを import
import { HttpClient, HttpHeaders } from '@angular/common/http';
@Injectable()
export class HttpClientService {
/**
* Http クライアントを実行する際のヘッダオプション
* @private
* @type {*}
* @memberof HttpClientService
* @description
* 認証トークンを使用するために `httpOptions` としてオブジェクトを用意した。
*/
private httpOptions: any = {
// ヘッダ情報
headers: new HttpHeaders({
'Content-Type': 'application/json'
}),
// DELETE 実行時に `body` が必要になるケースがあるのでプロパティとして用意しておく
// ( ここで用意しなくても追加できるけど... )
body: null
};
/**
* RST-API 実行時に指定する URL
*
* @private
* @memberof HttpClientService
* @description
* バックエンドは Express で実装し、ポート番号「3000」で待ち受けているため、
* そのまま指定すると CORS でエラーになる
* それを回避するため、ここではフロントエンドのポート番号「4200」を指定し、
* Angular CLI のリバースプロキシを利用してバックエンドとの通信を実現する
*/
private host: string = 'http://localhost:4200/app';
/**
* コンストラクタ. HttpClientService のインスタンスを生成する
*
* @param {Http} http Httpサービスを DI する
* @memberof HttpClientService
*/
constructor(private http: HttpClient) {
// `Authorization` に `Bearer トークン` をセットする
this.setAuthorization('my-auth-token');
}
/**
* HTTP GET メソッドを実行する
* (toPromise.then((res) =>{}) を利用する場合のコード)
*
* @returns {Promise<any>}
* @memberof HttpClientService
*/
public getCsv(): Promise<any> {
return this.http.get(this.host + '/csv', this.httpOptions)
.toPromise()
.then((res) => {
// response の型は any ではなく class で型を定義した方が良いが
// ここでは簡便さから any としておく
const response: any = res;
return response;
})
.catch(this.errorHandler);
}
/**
* REST-API 実行時のエラーハンドラ
* (toPromise.then((res) =>{}) を利用する場合のコード)
*
* @private
* @param {any} err エラー情報
* @memberof HttpClientService
*/
private errorHandler(err: any) {
console.log('Error occured.', err);
return Promise.reject(err.message || err);
}
/**
* Authorizatino に認証トークンを設定しする
*
* @param {string} token 認証トークン
* @returns {void}
* @memberof HttpClientService
* @description
* トークンを動的に設定できるようメソッド化している
* Bearer トークンをヘッダに設定したい場合はこのメソッドを利用する
*/
public setAuthorization(token: string = ''): void {
if (!token) {
return;
}
const bearerToken: string = `Bearer ${token}`;
this.httpOptions.headers = this.httpOptions.headers.set('Authorization', bearerToken);
}
}
バックエンド
CSV データを生成する API
from flask import Flask
from flask_restful import Resource
from datetime import datetime, timedelta
import csv
from io import StringIO
class Csv(Resource):
def get(self) -> dict:
"""CSVデータを作って返却する
Returns:
Response -- レスポンスオブジェクト
Description:
CSVファイルの出力は行わず、作成したデータとファイル名を返却する
"""
# CSVファイル名
# ファイル名のフォーマットは ${STR}_${STR}_${YYYYMMDD}. とし、${STR} は任意の文字列が入る
# ${YYYYMMDD} には西暦での年月日が入る
date_time = datetime.now().strftime('%Y%m%d')
output_path = '{}_{}_{}.csv'.format('好きな', '文字', date_time)
# CSVデータを返却する
f = StringIO()
writer = csv.writer(f, quotechar='"', quoting=csv.QUOTE_ALL, lineterminator="\r\n")
# ヘッダレコードとボディレコードを作る
header_record = [
'名前', '年齢', '住所', '電話番号', '備考'
]
body_record = [
'ほげ', '99歳', 'ほげ県ほげほげ市', '999-9999-9999', ''
]
writer.writerow(header_record)
writer.writerow(body_record)
res: dict = {
'fileName': output_path,
'csv': f.getvalue()
}
return res
動作確認
フロントエンドの起動
バックエンドとはオリジンが異なるため、プロキシ経由で起動させる。
$ npm start
プロキシの設定は以下のとおり。
{
"/app": {
"target": "http://localhost:5000", # バックエンドはポート: 5000 で待受け
"pathRewrite": {"^/app": ""}
}
}
バックエンドの起動
$ python3 app/run.py
出力される CSV ファイル
- デフォルトのファイル名:
好きな_文字_20210111.csv
(20210111
の部分は出力日付) - ファイルの内容
"名前","年齢","住所","電話番号","備考"
"ほげ","99歳","ほげ県ほげほげ市","999-9999-9999",""
具体的な説明
フロントエンド
フロントエンドは Angular アプリとして実装しているので、html を表現しているテンプレートと処理を実装してるコンポーネントに分けて説明する。
テンプレートについて
ボタン要素に加えて <a id="csv-donwload"></a>
でリンク要素を配置している点がポイント。
ボタン要素の (click)="outputCsv($event)"
で CSV出力処理が実行された際にこのリンクタグが活きてくる。
詳細は後述の 3. CSVファイルの出力 を参照。
コンポーネント について
コンポーネントにはテンプレートで「CSV出力」ボタンがクリックされたときの処理である outputCsv
を実装する。
ここでのポイントは大きくわけて 3 つ。
- ひとつめは REST-API によるCSVファイル出力ための情報の取得
- ふたつめは CSV ファイル出力のための準備
- みっつめは CSV ファイルの出力
以下、これらのポイントを細かくみていく。
1. REST-API によるCSVファイル出力ための情報の取得
//-------------------------------------------
// 1. REST-API を実行して CSV データを取得する
//-------------------------------------------
const res: any = await this.httpClientService.postCsv();
const csv = res.csv;
const filename = res.fileName;
細かくみていく、といったがここで語る詳細はあまりない。単純に Angular で Http クライアントを実装して、後述のバックエンドで提供している CSV データ取得のための API を実行するだけ。
Angualr における Http クライアントの実装については、僭越ながら こちらの記事 で触れているのでご興味あれば参照されたい。
2. CSV ファイル出力のための準備
//-------------------------------------------
// 2. レスポンスを加工してCSVファイルとURLを作る
//-------------------------------------------
// CSV ファイルは `UTF-8 BOM有り` で出力する
// そうすることで Excel で開いたときに文字化けせずに表示できる
const bom = new Uint8Array([0xEF, 0xBB, 0xBF]);
// CSVファイルを出力するために Blob 型のインスタンスを作る
const blob = new Blob([bom, csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
こちらは見るべき箇所が何点かあるので、ポイントごとに説明を。
-
Blob()
でインスタンスの生成- これは CSV ファイルを生成するためのもので、ここではコンストラクタの引数に注目
- コンストラクタ引数にはまず
[bom, csv]
として CSV データと共に UTF-8 の Byte Order Mark( BOM )として[0xEF, 0xBB, 0xBF]
を指定している - こうすることで BOM付きの UTF-8 で出力できる
- BOM 付きの UTF-8 で出力したい理由はコード中のコメントのとおりで、 Excel で開いたときの文字化けを防ぐため
- そして
Option
として MIME タイプに{ type: 'text/csv' }
を指定している
-
window.URL.createObjectURL()
で CSV ファイルの URL を生成- 生成した Blob インスタンスを引数に設定することで生成した CSV ファイルを指す URL が作られる
- これによって
URL == CSVファイル
ということになる - で、ここで生成した URL が後述の CSV ファイル出力 で利用される
3. CSV ファイルの出力
//-------------------------------------------
// 3. 出力はリンクタグのDOMを取得してそこから行う
//-------------------------------------------
// this.element は `ElementRef.nativeElement` から取得した `HTMLElement`
const link: HTMLAnchorElement = this.element.querySelector('#csv-donwload') as HTMLAnchorElement;
link.href = url;
link.download = filename;
link.click();
最後は CSV ファイルの出力処理。ここでは CSV ファイル出力のためにリンク要素である <a></a>
タグを取得している。
前項までの処理は UI からの「CSV出力」ボタンのクリックイベントを受けて「データを取得して」「CSVファイルの生成とダウンロードURLを生成した」だけで、これだけでは CSVファイルをダウンロードすることは出来ない。
<a></a>
タグ の href
属性に URL を設定し、download
属性にファイル名を設定し、click()
メソッドを実行させてようやく「バックエンドから取得したCSVデータを、同じくバックエンドから取得したファイル名で UTF-8 BOM付きでダウンロード(出力)する」という要件が完了する。
ポイントは次のとおり。
-
href
属性に Blob インスタンスから生成した URL をセットする。ただしこれだけでは ダウンロードは始まらない -
download
属性にファイル名をセットする。これを行わないと CSVファイル名に目的のファイル名がセットされない -
click()
を実行することでリンククリックアクションの実行を実現する
あとはおまけ程度であるが、DOM ( <a></a>
タグ ) の取得を
const link: HTMLAnchorElement = this.element.querySelector('#csv-donwload') as HTMLAnchorElement;
で行っている点も挙げておく。
これは一応 Angular アプリ上での実装なので、DOM の取得にあたり document.getElementById()
ではなくElementRef.nativeElement
から行なった。
それからもう一点。HTMLAnchorElement
でキャストしているのは TS2339
対策。
link.href = url;
link.download = filename;
link.click();
取得したDOMに対して href
, download
, click()
を行った際に
error TS2339: Property 'href' does not exist on type 'Element'.
error TS2339: Property 'download' does not exist on type 'Element'.
error TS2339: Property 'click' does not exist on type 'Element'.
が出るのを防ぐのが目的である。
4. フロントエンドのまとめ
大きく3つのポイントをあげたが、その中でも 3. CSV ファイルの出力 で行っているリンク要素の取得から始まる CSV ファイルのダウンロード処理が重要な点だ。
ボタン要素ではなくリンク要素のイベントとして (click)="outputCsv($event)"
を配置しても
- REST-API の実行
- Blob インスタンスの生成〜URL
-
download
属性にファイル名を設定
することまでは行える。
が、前述のとおりこれだけではCSVファイルのダウンロードは実行されない。click()
して初めて href
に設定した URL からダウンロードが実行される。
ではここで click()
を実行するのはどれかというと、すでにクリックされたリンク要素である <a></a>
タグ自身である。
で、それをするとどうなるかというと、ダウンロードのためのダイアログが表示されたあとに、また REST-API の実行から始まる一連の処理が実行される。
このループを回避する意味もあり、
- UI にボタン配置
- ボタンクリックによる CSV データの取得から
<a></a>
タグの生成とダウンロード
で要件を実現させている。
バックエンド
バックエンドは Python のマイクロフレームワークである Flask を採用していて、REST-API の実装にあたりflask_restful
を使用している。エンドポイントは GET
で待ち受け。
※ Flask や flask_restful についての説明はここではしない。
CSV データの生成
# CSVデータを返却する
f = StringIO()
writer = csv.writer(f, quotechar='"', quoting=csv.QUOTE_ALL, lineterminator="\r\n")
# ヘッダレコードとボディレコードを作る
header_record = [
'名前', '年齢', '住所', '電話番号', '備考'
]
body_record = [
'ほげ', '99歳', 'ほげ県ほげほげ市', '999-9999-9999', ''
]
writer.writerow(header_record)
writer.writerow(body_record)
ポイントは上記の CSV データの生成部分で、注目するのは 2 点。
まずひとつめ。
この API では CSV のフォーマットに整形したデータを返却する ので、StringIO
のインスタンスをファイルとして扱い、そこに書き込まれた( 設定された )文字列を CSV データとしてレスポンスに設定している。
次いでふたつめ。
CSVデータの出力は writerow
メソッドで行うのだが、ここで当該メソッドの引数に設定しているヘッダやボディのレコードを List
で設定していること。出力するデータを List
で渡すことで各項目がカンマ区切りで1レコードに出力される。
ここで writerow
に渡すデータをList
に設定せずに 文字列として設定した場合、意図せずに余計なカンマ が付与されてしまうので注意。
CSV ファイルの出力で出来なかったこと
Flask で CSV ファイルを出力するAPIを実装する、という要件について調べていたとき、次のようなサンプルコードをよく目にした。
#
# レスポンスデータとして CSVファイルを作成
#
f = StringIO()
writer = csv.writer(f, quotechar='"', quoting=csv.QUOTE_ALL, lineterminator="\r\n")
# ヘッダレコードとボディレコードを作る
header_record = [
'名前', '年齢', '住所', '電話番号', '備考'
]
body_record = [
'ほげ', '99歳', 'ほげ県ほげほげ市', '999-9999-9999', ''
]
writer.writerow(header_record)
writer.writerow(body_record)
# レスポンスのインスタンスを生成
res = make_response()
# ファイルインスタンスからデータを取得して UTF-8 BOMあり でレスポンスにセット
res.data = f.getvalue().encode('utf_8_sig')
# レスポンスヘッダに CSV ファイルであることを設定する
# 非ASCIIのファイル名だとエラーになるので `unicode-escape` を指定してエンコードする
res.headers['Content-Type'] = 'text/csv'
res.headers['Content-Disposition'] = 'attachment; filename={}'.format('非ASCIIのファイル名'.encode('unicode-escape'))
実際のところ、Postman 上で確認した限りでは上記コードでCSVデータは作成され、ヘッダ情報も設定した内容が含まれていた。
だがこれを Angular アプリから実行したときに
- API の実行結果を受けても、そのままではCSVファイルのダウンロードが行われなかった
- そのため、結局は 2. CSV ファイル出力のための準備 でやったように BOMありを指定したうえで
Blob
のインスタンスを生成して URL を作らなければならなかった - しかもこのケースだと CSV ファイル名をヘッダから取得しなければならず、しかもエンコードされたファイル名をデコードする必要があった ( が、これは面倒だったので試行していない )
という結果になった。
で、思ったことが、それならば最初から「ファイル名」と「CSVデータ」を JSON で貰って、そのデータをもとにCSVファイル出力をフロントエンドで実装した方が面倒が少ない、ということで本記事の内容となった。
ソースコード
今回の記事で作成したコードは 以下にアップしてあるのでご参考まで。