こんにちは、白川です。
最近はバックエンドだけでなくフロントエンドも担当することがあり、Angular を触る機会が増えています。
今回は Angular の勉強がてら、Angular in-memory-web-api がどのような仕組みで動作しているかを見てみました。
Angular in-memory-web-api とは
Angular in-memory-web-api は、REST API による CRUD 操作をエミュレートするインメモリの Web API です。
バックエンドサーバが完成していなくても、CRUD データの永続化操作をシミュレートしてくれるため、Angular アプリの開発時やテスト時に Web API モックとして使用されます。
Angular in-memory-web-api の使い方ですが、
まず、以下のような感じで Web API のモックとして、レスポンスデータ等を定義するデータストアサービスを実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import { InMemoryDbService } from 'angular-in-memory-web-api'; export class InMemHeroService implements InMemoryDbService { createDb() { let heroes = [ { id: 1, name: 'Windstorm' }, { id: 2, name: 'Bombasto' }, { id: 3, name: 'Magneta' }, { id: 4, name: 'Tornado' } ]; return {heroes}; } } |
そして、Angular in-memory-web-api が用意した HttpClientInMemoryWebApiModule
に上記データストアサービスを登録し、
モックサーバとして動作させるために、AppModule
に以下のように追加します。
(AppModule
は、Angular アプリが起動するときに、最初に立ち上がるルートモジュールのことです。)
この時、HttpClientInMemoryWebApiModule
は HttpClientModule
より後にインポートする必要があります。
この理由は後述します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import { HttpClientModule } from '@angular/common/http'; import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api'; import { InMemHeroService } from '../app/hero.service'; @NgModule({ imports: [ HttpClientModule, HttpClientInMemoryWebApiModule.forRoot(InMemHeroService), ... ], ... }) export class AppModule { ... } |
上記の設定をするだけで、HttpClient
経由でモックデータを取得することが可能となります。
( HttpClient
は、Angular アプリケーション用のシンプルなクライアント HTTP API を提供するクラスです。)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
@Injectable() export class HttpClientHeroService { constructor (private http: HttpClient) { super(); } getHeroes (): Observable<Hero[]> { return this.http.get<Hero[]>('api/heroes').pipe( /** * console.log * [ * { id: 1, name: 'Windstorm' }, * { id: 2, name: 'Bombasto' }, * { id: 3, name: 'Magneta' }, * { id: 4, name: 'Tornado' } * ] */ tap(data => console.log(data)), catchError(this.handleError) ); } |
では、少し Angular の内部に入って見ていくことにします。
HttpClientModule と Angular の依存性注入(DI)
Angular モジュールとは、Angular アプリを構成する部品をまとめておく仕組みのことで、
Angular も複数のモジュールから構成されており、アプリに必要なモジュールをインポートして利用します。
( Angular モジュールの詳細はこちらをご覧ください。)
Angular in-memory-web-api を利用するため、
Angular のルートモジュールである AppModule
に HttpClientModule
と、HttpClientInMemoryWebApiModule
をモジュールとしてインポートしていました。
1 2 3 4 5 6 7 8 9 |
@NgModule({ imports: [ HttpClientModule, HttpClientInMemoryWebApiModule.forRoot(InMemHeroService), ... ], ... }) export class AppModule { ... } |
まず、HttpClientModule
の中身を見ていきましょう。
ここで providers
に注目してみます。
providers
の中に複数の設定が見られますね。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
/** * Configures the [dependency injector](guide/glossary#injector) for `HttpClient` * with supporting services for XSRF. Automatically imported by `HttpClientModule`. * * You can add interceptors to the chain behind `HttpClient` by binding them to the * multiprovider for built-in [DI token](guide/glossary#di-token) `HTTP_INTERCEPTORS`. * * @publicApi */ @NgModule({ /** * Optional configuration for XSRF protection. */ imports: [ HttpClientXsrfModule.withOptions({ cookieName: 'XSRF-TOKEN', headerName: 'X-XSRF-TOKEN', }), ], /** * Configures the [dependency injector](guide/glossary#injector) where it is imported * with supporting services for HTTP communications. */ providers: [ HttpClient, {provide: HttpHandler, useClass: HttpInterceptingHandler}, HttpXhrBackend, {provide: HttpBackend, useExisting: HttpXhrBackend}, BrowserXhr, {provide: XhrFactory, useExisting: BrowserXhr}, ], }) export class HttpClientModule { } |
providers
は、ビジネスロジックを含むサービスクラスを Angular の依存性の注入 (DI) インジェクターに登録し、
実際に依存性を注入する際のサービスクラスのインスタンス作成の方法を指定しています。
上記は省略された表記で、省略せずに書くとこんな感じになります。
1 2 3 4 5 6 7 8 |
providers: [ {provide: HttpClient, useClass: HttpClient, multi: false}, {provide: HttpHandler, useClass: HttpInterceptingHandler, multi: false}, {provide: HttpXhrBackend, useClass: HttpXhrBackend, multi: false}, {provide: HttpBackend, useExisting: HttpXhrBackend, multi: false}, {provide: BrowserXhr, useClass: BrowserXhr, multi: false} {provide: XhrFactory, useExisting: BrowserXhr, multi: false}}, ], |
provide
というのは、DI トークンのことで、依存関係を要求されたときに参照する内部トークンプロバイダーマップにおけるルックアップキーです。
ここではクラス型を provide
として指定しており、依存関係を要求する側のコンポーネントなどのコンストラクタの引数の型指定をDIトークンと同じにすることで、
DI インジェクターからインスタンスを取得することができます。
以下の例では、インジェクターから HttpClient
のインスタンスを取得しています。
1 |
constructor (private http: HttpClient) {} |
useClass
、useExisting
はインスタンスの作り方を指定しています。
useClass
は値としてクラスを受け取り、依存関係を要求されるたびにそのクラスのインスタンスを作り直します。
Angular は階層性をもった DI インジェクターシステムを採用しており、
アプリのコンポーネントツリーと並行する形でインジェクターツリーを持ちます。
AppModule が持つ DI インジェクターはアプリ全体のインジェクター階層のルートです。
依存関係を要求する側であるコンポーネントも DI インジェクターを持つことができますが、
自身の DI インジェクターに要求された DI トークンが存在しない場合は、親の DI インジェクターを参照します。
この場合、インスタンスの生成は親の DI インジェクターが行なうため、useClass
を指定していたとしても、
ルートモジュールで、コンポーネントに注入されるサービスクラスを useClass
指定で登録することで、全て同じインスタンスを参照することになります。
useExisting
は、値として DI トークンを受け取り、指定されたDIトークンに対するエイリアスを作ります。
異なる DI トークンで、同じインスタンスを得られるようになります。
下記の例だと、HttpBackend
という DI トークンで、HttpXhrBackend
のインスタンスを得ることができます。
1 2 |
{provide: HttpXhrBackend, useClass: HttpXhrBackend, multi: false}, {provide: HttpBackend, useExisting: HttpXhrBackend, multi: false}, |
multi
は同じ DI トークンで、複数の値を得たい場合に使用します。
省略した場合は、false になるため、HttpClientModule
では全て false です。
AppModule
の中で HttpClientModule
をインポートすることで、
HttpClient
、HttpInterceptingHandler
、HttpXhrBackend
などが DI インジェクターに登録され、
アプリケーション全体で使用できるようになります。
DI のイメージはこんな感じです。
多段インジェクション
次に、HttpClientModule
で provider 指定されている HttpClient
を見ていきます。
コンストラクターの引数に HttpHandler
が指定されているため、
自身のコンストラクタが呼ばれた場合、HttpHandler
型のインスタンスを要求し、それを handler
という private 変数にインジェクションされます。
HTTP リクエストの実行は、request
メソッドで行なっていますが、
実際には HttpHandler
に処理を委譲していることが読み取れます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@Injectable() export class HttpClient { constructor(private handler: HttpHandler) {} (中略) request(first: string|HttpRequest<any>, url?: string, options: { body?: any, headers?: HttpHeaders|{[header: string]: string | string[]}, observe?: HttpObserve, params?: HttpParams|{[param: string]: string | string[]}, reportProgress?: boolean, responseType?: 'arraybuffer'|'blob'|'json'|'text', withCredentials?: boolean, } = {}): Observable<any> { (中略) // Start with an Observable.of() the initial request, and run the handler (which // includes all interceptors) inside a concatMap(). This way, the handler runs // inside an Observable chain, which causes interceptors to be re-run on every // subscription (this also makes retries re-run the handler, including interceptors). const events$: Observable<HttpEvent<any>> = of (req).pipe(concatMap((req: HttpRequest<any>) => this.handler.handle(req))); |
次に、HttpHandler
を見ていきましょう。実体は、HttpInterceptingHandler
クラスです。
1 |
{provide: HttpHandler, useClass: HttpInterceptingHandler}, |
自身のコンストラクタが呼ばれた場合、HttpBackend
型のインスタンスを要求し、それを backend
という private 変数にインジェクションされます。
(インターセプタが指定されていれば)先にインターセプタが実行され、その後に HttpBackend
にリクエスト処理を委譲しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
/** * An injectable `HttpHandler` that applies multiple interceptors * to a request before passing it to the given `HttpBackend`. * * The interceptors are loaded lazily from the injector, to allow * interceptors to themselves inject classes depending indirectly * on `HttpInterceptingHandler` itself. * @see `HttpInterceptor` */ @Injectable() export class HttpInterceptingHandler implements HttpHandler { private chain: HttpHandler|null = null; constructor(private backend: HttpBackend, private injector: Injector) {} handle(req: HttpRequest<any>): Observable<HttpEvent<any>> { if (this.chain === null) { const interceptors = this.injector.get(HTTP_INTERCEPTORS, []); this.chain = interceptors.reduceRight( (next, interceptor) => new HttpInterceptorHandler(next, interceptor), this.backend); } return this.chain.handle(req); } } |
次に、HttpBackend
を見ていきましょう。その実体は、HttpXhrBackend
クラスです。
1 2 |
HttpXhrBackend, {provide: HttpBackend, useExisting: HttpXhrBackend}, |
handle
メソッド内部で、XMLHttpRequest
を生成して、HTTP リクエストを送信していることが分かります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
/** * An `HttpBackend` which uses the XMLHttpRequest API to send * requests to a backend server. * * @publicApi */ @Injectable() export class HttpXhrBackend implements HttpBackend { constructor(private xhrFactory: XhrFactory) {} /** * Process a request and return a stream of response events. */ handle(req: HttpRequest<any>): Observable<HttpEvent<any>> { (中略) // Everything happens on Observable subscription. return new Observable((observer: Observer<HttpEvent<any>>) => { // Start by setting up the XHR object with request method, URL, and withCredentials flag. const xhr = this.xhrFactory.build(); xhr.open(req.method, req.urlWithParams); (中略) // Fire the request, and notify the event stream that it was fired. xhr.send(reqBody !); observer.next({type: HttpEventType.Sent}); // This is the return from the Observable function, which is the // request cancellation handler. return () => { // On a cancellation, remove all registered event listeners. xhr.removeEventListener('error', onError); xhr.removeEventListener('load', onLoad); if (req.reportProgress) { xhr.removeEventListener('progress', onDownProgress); if (reqBody !== null && xhr.upload) { xhr.upload.removeEventListener('progress', onUpProgress); } } // Finally, abort the in-flight request. xhr.abort(); }; }); } } |
HttpClient
-> HttpHandler
-> HttpBackend
と多段に関連するサービスクラスがインジェクションされていって、
HTTP リクエスト処理のそのものは、以下の図のように HttpBackend
クラスが担っていることが分かりました。
HttpClientInMemoryWebApiModule は何をしているのか
では、HttpClientInMemoryWebApiModule
は何をしているのでしょうか。
AppModule
では、以下のように指定していました。
1 2 3 4 5 6 7 8 9 |
@NgModule({ imports: [ HttpClientModule, HttpClientInMemoryWebApiModule.forRoot(InMemHeroService), ... ], ... }) export class AppModule { ... } |
HttpClientInMemoryWebApiModule
の forRoot
メソッドの中を見ていきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
@NgModule({}) export class HttpClientInMemoryWebApiModule { /** * Redirect the Angular `HttpClient` XHR calls * to in-memory data store that implements `InMemoryDbService`. * with class that implements InMemoryDbService and creates an in-memory database. * * Usually imported in the root application module. * Can import in a lazy feature module too, which will shadow modules loaded earlier * * @param {Type} dbCreator - Class that creates seed data for in-memory database. Must implement InMemoryDbService. * @param {InMemoryBackendConfigArgs} [options] * * @example * HttpInMemoryWebApiModule.forRoot(dbCreator); * HttpInMemoryWebApiModule.forRoot(dbCreator, {useValue: {delay:600}}); */ static forRoot(dbCreator: Type<InMemoryDbService>, options?: InMemoryBackendConfigArgs): ModuleWithProviders { return { ngModule: HttpClientInMemoryWebApiModule, providers: [ { provide: InMemoryDbService, useClass: dbCreator }, { provide: InMemoryBackendConfig, useValue: options }, { provide: HttpBackend, useFactory: httpClientInMemBackendServiceFactory, deps: [InMemoryDbService, InMemoryBackendConfig, XhrFactory]} ] }; } (以下略) |
forRoot
メソッドの中の第一引数に指定した値を、DI トークン InMemoryDbService
として、
第2引数に指定した値を、DI トークン InMemoryBackendConfig
としてインジェクターに登録しています。
1 2 3 |
providers: [ { provide: InMemoryDbService, useClass: dbCreator }, { provide: InMemoryBackendConfig, useValue: options }, |
そして、DI トークン HttpBackend
に、useFactory: httpClientInMemBackendServiceFactory
を登録しています。
1 2 3 4 |
{ provide: HttpBackend, useFactory: httpClientInMemBackendServiceFactory, deps: [InMemoryDbService, InMemoryBackendConfig, XhrFactory]} ] |
ここで、もう一度 HttpClientModule
の providers
を見てみると、HttpBackend
という DI トークンが宣言されています。
1 2 3 4 5 6 7 8 |
providers: [ HttpClient, {provide: HttpHandler, useClass: HttpInterceptingHandler}, HttpXhrBackend, {provide: HttpBackend, useExisting: HttpXhrBackend}, BrowserXhr, {provide: XhrFactory, useExisting: BrowserXhr}, ], |
同じ DI トークンが providers
内で複数回宣言された場合、後勝ちになります。
つまり、以下の図のように HttpBackend
という DI トークンで得られるインスタンスが、HttpXhrBackend
クラスから、useFactory: httpClientInMemBackendServiceFactory
で生成されるインスタンスに置き換えられます。
(HttpClientInMemoryWebApiModule
は HttpClientModule
より後にインポートする必要がある、という理由がここで分かりました。)
useFactory
には関数を指定し、依存関係を要求されるたびに関数が実行され、その関数の戻り値がインジェクションされます。
deps
には、useFactory
で指定した関数に渡す引数を指定します。
httpClientInMemBackendServiceFactory
を見てみると、HttpClientBackendService
クラスのインスタンスを生成して返しているのが分かります。
この HttpClientBackendService
が HTTP リクエストを処理することで Web API モックを実現しているという訳です。
1 2 3 4 5 6 7 8 9 10 |
// Internal - Creates the in-mem backend for the HttpClient module // AoT requires factory to be exported export function httpClientInMemBackendServiceFactory( dbService: InMemoryDbService, options: InMemoryBackendConfig, xhrFactory: XhrFactory, ): HttpBackend { const backend: any = new HttpClientBackendService(dbService, options, xhrFactory); return backend; } |
モックの実際の動き
HttpClientBackendService
クラスを見ると、処理の実体は handleRequest
メソッドにありそうな感じです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
@Injectable() export class HttpClientBackendService extends BackendService implements HttpBackend { constructor( inMemDbService: InMemoryDbService, @Inject(InMemoryBackendConfig) @Optional() config: InMemoryBackendConfigArgs, private xhrFactory: XhrFactory ) { super(inMemDbService, config); } handle(req: HttpRequest<any>): Observable<HttpEvent<any>> { try { return this.handleRequest(req); } catch (error) { const err = error.message || error; const resOptions = this.createErrorResponseOptions(req.url, STATUS.INTERNAL_SERVER_ERROR, `${err}`); return this.createResponse$(() => resOptions); } } (以下略) |
handleRequest
メソッドは、親クラス BackendService
にあるので、そちらを見てみます。
Request evaluation order に、モック内部でどのように HTTP リクエストの処理が行われているかが記載されていますが、handleRequest
メソッドに処理が実装されていることが分かります。
(以下引用です)
- If it looks like a command, process as a command
- If the HTTP method is overridden, try the override.
- If the resource name (after the api base path) matches one of the configured collections, process that
- If not but the Config.passThruUnknownUrl flag is true, try to pass the request along to a real XHR.
- Return a 404.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
export abstract class BackendService { (中略) constructor( protected inMemDbService: InMemoryDbService, config: InMemoryBackendConfigArgs = {} ) { const loc = this.getLocation('/'); this.config.host = loc.host; // default to app web server host this.config.rootPath = loc.path; // default to path when app is served (e.g.'/') Object.assign(this.config, config); } (中略) protected handleRequest(req: RequestCore): Observable<any> { // handle the request when there is an in-memory database return this.dbReady.pipe(concatMap(() => this.handleRequest_(req))); } protected handleRequest_(req: RequestCore): Observable<any> { const url = req.urlWithParams ? req.urlWithParams : req.url; // Try override parser // If no override parser or it returns nothing, use default parser const parser = this.bind('parseRequestUrl'); const parsed: ParsedRequestUrl = ( parser && parser(url, this.requestInfoUtils)) || this.parseRequestUrl(url); const collectionName = parsed.collectionName; const collection = this.db[collectionName]; const reqInfo: RequestInfo = { req: req, apiBase: parsed.apiBase, collection: collection, collectionName: collectionName, headers: this.createHeaders({ 'Content-Type': 'application/json' }), id: this.parseId(collection, collectionName, parsed.id), method: this.getRequestMethod(req), query: parsed.query, resourceUrl: parsed.resourceUrl, url: url, utils: this.requestInfoUtils }; let resOptions: ResponseOptions; if (/commands\/?$/i.test(reqInfo.apiBase)) { return this.commands(reqInfo); } const methodInterceptor = this.bind(reqInfo.method); if (methodInterceptor) { // InMemoryDbService intercepts this HTTP method. // if interceptor produced a response, return it. // else InMemoryDbService chose not to intercept; continue processing. const interceptorResponse = methodInterceptor(reqInfo); if (interceptorResponse) { return interceptorResponse; }; } if (this.db[collectionName]) { // request is for a known collection of the InMemoryDbService return this.createResponse$(() => this.collectionHandler(reqInfo)); } if (this.config.passThruUnknownUrl) { // unknown collection; pass request thru to a "real" backend. return this.getPassThruBackend().handle(req); } // 404 - can't handle this request resOptions = this.createErrorResponseOptions( url, STATUS.NOT_FOUND, `Collection '{collectionName}' not found` ); return this.createResponse$(() => resOptions); } |
また、bind
というメソッドで、InMemoryDbService
に実装されている URL パーサや、HTTP メソッドインターセプターを取得しています。
InMemoryDbService
の実装クラスで parseRequestUrl
というメソッドをオーバーライドし、リクエスト URL の解釈を変更したり、
get
や post
というメソッドを実装することで、該当する HTTP メソッドの動きをカスタマイズしたりできます。
1 2 3 4 5 6 7 |
/** * Get a method from the `InMemoryDbService` (if it exists), bound to that service */ protected bind<T extends Function>(methodName: string) { const fn = this.inMemDbService[methodName] as T; return fn ? <T> fn.bind(this.inMemDbService) : undefined; } |
おわりに
Angular in-memory-web-api の使い方だけみても簡単に使えそうだけどイマイチ仕組みが分からないので、カスタマイズできるポイントがどこなのかがよく分からなかったのですが、ソースコードを読んでいくことで仕組みや、どういったカスタマイズができるか理解できました。
最後に、Angular の DI や、HttpClient の仕組みの理解の助けになる文献を載せておきます。