超お久しぶりです。 たーせるです。
どういうわけか疲労極まっており、いろいろ端折っております。 それでもなんとか書き留めておきたいので、ここに残しておくことにします。
ナイスな導入
今日は、Angular で Submit ボタンを連打されないようにするにはどうすればよいか考えてみたいと思います。
やりたかったことは、Angularで多重送信を食い止める仕組みを作ること。
— たーせる (@tercel_s) 2019年5月20日
① HttpInterceptorの入り口で、パラメータの HttpRequestをハッシュ化し、非同期通信が解決するまでどこかに保持しておく。
② 既に同じハッシュ値がいたら、多重送信とみなして通信をキャンセルする。
……というのが、基本的な思想になります。
本日の材料
材料は以下の通りです。
xxhash-wasm は、xxHash アルゴリズムの WebAssembly 実装だ。モダンブラウザで高速に動作する。
— たーせる (@tercel_s) 2019年5月20日
TypeScript の型定義ファイルが無いが、API 自体はミニマルなので問題にはならない。
それではさっそくパッケージを導入しましょう。
パッケージのインストール
ターミナルで、package.json があるフォルダに $cd
して、以下のコマンドを入力します。
$npm i safe-json-stringify @types/safe-json-stringify xxhash-wasm
残念ながら xxhash-wasm には TypeScript の型定義が無いので、ライブラリ本体だけを入れることになります。
もう一つ残念なのが、xxhash-wasm のハッシュ化メソッドは非同期処理であるということ。
ここで、ハッシュ値を求めるために safe-json-stringify と xxhash-wasm を入れた。
— たーせる (@tercel_s) 2019年5月20日
使うだけなら簡単だが、ハッシュ値を求める処理が非同期になっているのが少しややこしい。
from(xxhash())
.subscribe((hasher: any) =>
hasher.h64(SafeJsonStringify(req)));
これをそのまま HttpInterceptor の中で使おうとすると、軽く嵌ります。
ソースファイルの作成
とりあえず、以下のが付いているフォルダとファイルを作りましょう。
single-access-management.service.ts
import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class SingleAccessManagementService { private readonly requests = new Set<string>(); add(hash: string) { if (this.requests.has(hash)) { return false; } this.requests.add(hash); return true; } delete(hash: string) { if (!this.requests.has(hash)) { return false; } this.requests.delete(hash); return true; } }
single-access-management.service.ts は、中に Set<string>
を隠し持ったシングルトンクラスです。 要素を新規追加する add()
と、削除する delete()
メソッドを持っています。
もし、追加しようとしている要素が既に存在する場合、add()
メソッドは false
(= 追加失敗)を返します。これは、この後登場する single-access-interceptor.ts の実装をラクにするための工夫です。
single-access-interceptor.ts
import { Injectable } from '@angular/core'; import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http'; import { Observable, Subject, EMPTY, from } from 'rxjs'; import { flatMap, finalize } from 'rxjs/operators'; import * as stringify from 'safe-json-stringify'; import xxhash from 'xxhash-wasm'; import { SingleAccessManagementService } from './single-access-management.service'; @Injectable() export class SingleAccessInterceptor implements HttpInterceptor { constructor(private manager: SingleAccessManagementService) { } private getRequestHash(req: HttpRequest<any>) { const ret = new Subject<string>(); from(xxhash()).subscribe((hasher: any) => ret.next(hasher.h64(stringify(req)))); return ret.asObservable(); } intercept(req: HttpRequest<any>, next: HttpHandler) { return this.getRequestHash(req).pipe( flatMap(hash => this.manager.add(hash) ? next.handle(req).pipe(finalize(() => this.manager.delete(hash))) : EMPTY)); } }
ここがポイントです。
普通なら map オペレータを使えば造作も無い事だが、今回はそうはいかない。
— たーせる (@tercel_s) 2019年5月20日
HttpHandler#next()自体が、Observableを生み出すメソッドなので、そのまま map すると、Observable<Observable<HttpEvent<any>>> と一皮かぶってしまう。
こういう場合に flatMap を使うと幸せになるのだ。
何を言っているかわからないと思いますが、解説する余力がないのでこの話はまたいつか……。
index.ts
import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { SingleAccessInterceptor } from './single-access-interceptor'; export const httpInterceptorProviders = [ { provide: HTTP_INTERCEPTORS, useClass: SingleAccessInterceptor, multi: true } ];
最後にバレルを作り、これを app.module.ts に登録します。
app.module.ts
import { AppComponent } from './app.component'; import { httpInterceptorProviders } from './http-interceptors'; /* 略 */ @NgModule({ declarations: [ /* 略 */ ], imports: [ /* 略 */ ], providers: [ httpInterceptorProviders ], bootstrap: [ AppComponent ] }) export class AppModule { }
これで、とりあえず最低限の連打防止制御ができるようになりました。めでたし。