Angular＋NestJS＋OpenAPI（Swagger）でマイクロサービスを視野に入れた環境を考える

Angular + NestJS + OpenAPIの環境について、以前に書きました。
[MEANスタックはもう古い？Angular+Nest.js+OpenAPIでTypescriptオンリー環境を構築する](https://qiita.com/teracy55/items/1e862b954875f81b5b41)

ただ、この記事で書いた環境だと、クライアントとサーバーのコードが1プロジェクトになっているため、機能が増えると煩雑になりがちです。
今回はモノリポで構築しつつ、マイクロサービスも視野に入れた構成を考えます。

（2020/11/06 追記）
シンプルに作る場合の記事を新たに執筆しました。
[Angular＋NestJSのモノリポ環境をなるべくシンプルに作る](https://qiita.com/teracy55/items/4429645ad1fef081f6db)


# 作る環境

## イメージ
![Untitled Diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/7883cfc2-a9a2-6d09-c940-35f0a9b43fca.png)

## プロジェクト構成

angular-nest
└　packages
　　└　server　　　・・・　NestJS + OpenAPIによるAPIサーバー
　　└　client　　　・・・　Angularワークスペース
　　　　└　projects
　　　　　　└　api-client 　・・・　OpenAPIクライアントライブラリ
　　　　　　└　client1　　　・・・ Angularプロジェクト１アプリケーション
　　　　　　└　client2　　　・・・ Angularプロジェクト２アプリケーション

## URL設計

今回はサブディレクトリで参照先を分岐させたいと思います

|パス|対象|
|---|---|
|/api|NestJSのRestAPI|
|/client1|Angularプロジェクト１|
|/client2|Angularプロジェクト２|





# 環境構築

## Lernaプロジェクトを生成

サーバー／クライアントを1プロジェクトで管理するため、npmプロジェクト向けモノリポ管理ツールである[Lerna](https://github.com/lerna/lerna)を導入します


[公式の手順](https://github.com/lerna/lerna#getting-started)通りにLernaプロジェクトを生成します

```
mkdir angular-nest && cd angular-nest
npx lerna init
```



## クライアントサイドのプロジェクト作成

マイクロサービス前提での構築とするため、クライアントサイドは[MultiProject](https://angular.jp/guide/file-structure#multiple-projects)での構築を行います

### 前提

- `@angular/cli`導入済み


### Angularのワークスペースを生成

```
cd packages
ng new client --createApplication="false"
```


### クライアントプロジェクトを生成

- `client1`と`client2`を生成します

```
cd client
ng generate application client1 --routing --style=scss
ng generate application client2 --routing --style=scss
```
→通常のAngularプロジェクトと異なり、projectsディレクトリの下に各プロジェクトが生成されます。

### 区別がつくように画面を修正

```packages/client/projects/client1/src/app/app.component.html
{{title}}
```
```packages/client/projects/client1/src/app/app.component.ts
title = 'Hello Client1';
```

```packages/client/projects/client2/src/app/app.component.html
{{title}}
```
```packages/client/projects/client2/src/app/app.component.ts
title = 'Hello Client2';
```
### サブディレクトリの設定

今回は`/client1`と`/client2`でプロジェクトを分岐させるため、Angularの設定をいじる必要があります
→`baseHref`と`deployUrl`を追記します

```packages/client/angular.json
    "client1": {
      ・・・
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "baseHref": "/client1/",
            "deployUrl": "/client1/",
          ・・・
    "client2": {
      ・・・
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "baseHref": "/client2/",
            "deployUrl": "/client2/",
```

ビルドします

```
npm run build client1
npm run build client2
```







## サーバーサイドプロジェクトを作成

### 前提

- `nestjs/cli`導入済み

### NestJSテンプレートプロジェクトを生成

```
cd packages
nest new server
```
→npm/yarnどちらか聞かれるので、今回はnpmで生成します


### サーバーとクライアントの連携

今回はとりあえず一つのサーバー上で動かすので、NestJSでAngularプロジェクトの振り分けをします。
※実際にマイクロサービスにする場合は、Nginx等をかませて参照サーバーを振り分ければ良いかと思います。

```packages/server/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as express from 'express';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // APIは/apiでアクセス
  app.setGlobalPrefix('api');

  // フロントの参照先を設定
  const clientPath = __dirname + '/../../client/dist';
  app.use(express.static(clientPath));
  // client1プロジェクトは/client1の時に参照
  app.use(/^\/client1.*$/, express.static(clientPath + '/client1/index.html'));
  // client2プロジェクトは/client2の時に参照
  app.use(/^\/client2.*$/, express.static(clientPath + '/client2/index.html'));

  await app.listen(3000);
}
bootstrap();
```







# 動作確認

ここまでで、最小構成の環境ができたので動作確認をしてみます。
※ちなみに、この環境はVirtualBOX＋Vagrantの仮想マシン上で試しています。

- サーバーを起動します

```
cd packages/server
npm run start
```

- `http://192.168.33.10:3000/client1`にアクセス  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/1f750d05-8986-fe50-911f-a8eec9d49a83.png)

- `http://192.168.33.10:3000/client2`にアクセス
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/261e59e8-c313-5998-22d8-2f04ef3dbd97.png)

- `http://192.168.33.10:3000/api`にアクセス
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/1ea98bec-7200-851f-853b-16b61534ca0d.png)


ちゃんとclient1とclient2の呼び分け、APIへのアクセスができてます！







# OpenAPIの導入

## NestJSのOpenAPI対応

- [公式の手順](https://docs.nestjs.com/recipes/swagger)に従って、nestjs/swaggerをインストールします

```
npm install --save @nestjs/swagger swagger-ui-express
```
※packages/server下で実行してください

- main.ts修正
    - DocumentBuilderおよびSwaggerModuleでOpenAPIの設定をします
    - `SwaggerModule.setup`の第一引数はSwaggerUI表示用のURLのため、今回は`/api/docs`で表示できるようにします

```packages/server/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as express from 'express';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // APIは/apiでアクセス
  app.setGlobalPrefix('api');

  // OpenAPI(Swagger)設定
  const options = new DocumentBuilder()
    .setTitle('Cats example')
    .setDescription('The cats API description')
    .setVersion('1.0')
    .addTag('cats')
    .build();
  const document = SwaggerModule.createDocument(app, options);
  SwaggerModule.setup('api/docs', app, document);

  // フロントの参照先を設定
  const clientPath = __dirname + '/../../client/dist';
  app.use(express.static(clientPath));
  // client1プロジェクトは/client1の時に参照
  app.use(/^\/client1.*$/, express.static(clientPath + '/client1/index.html'));
  // client2プロジェクトは/client2の時に参照
  app.use(/^\/client2.*$/, express.static(clientPath + '/client2/index.html'));

  await app.listen(3000);
}
bootstrap();
```

### APIを修正

- Dtoクラスを作成します。

```packages/server/src/dto/hello.dto.ts
import { ApiModelProperty } from '@nestjs/swagger';

export class HelloDto {
    @ApiModelProperty()
    message: string;
}
```

- コントローラーで作成したDtoクラスを返すようにします
    - `@ApiResponse`でHelloDtoを型に指定します

```packages/server/src/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { HelloDto } from './dto/hello.dto';
import { ApiOperation, ApiResponse } from '@nestjs/swagger';

@Controller()
export class AppController {
    constructor(private readonly appService: AppService) { }

    @Get()
    @ApiResponse({ status: 200, type: HelloDto })
    getHello(): HelloDto {
        const message = this.appService.getHello();
        return { message };
    }
}
```

### SwaggerUIの確認

`npm run start`でサーバーを起動し、`http://192.168.33.10:3000/api/docs`にアクセスします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/5ca604a7-d514-4d14-c84c-b4e4103db578.png)

→SwaggerUIが表示され、HelloDtoもModelとして認識されています

また、`/api/docs-json`とすればSpecファイル（json）の内容が出力されます


## Angular側のAPIクライアント自動生成

クライアント側で共通で利用するため、ライブラリ化します。

### APIクライアントライブラリ生成

```
cd packages/client
ng generate library api-client
```

- ここはopenapi-generatorによる自動生成ファイル置き場となるため、ライブラリの中身を削除します

```
rm -rf projects/api-client/src/lib/*
```

### openapi-generatorの導入

openapi-generatorはJavaで作られているツールのため、普通に動かそうとするとJREが必要でめんどくさいので、docker方式を採用します。

毎回コマンドを打つのはめんどくさいので、package.jsonにスクリプトを登録しておきます。
【オプションについて】

- `-i` 先ほどのspecファイル表示URLを指定します
- `-g` 出力形式（typescript-angular）を指定します。
- `-o` docker内での出力ディレクトリを指定します


```packages/client/package.json
  "scripts": {
    ・・・
    "generate:api-client": "docker run --rm -v ${PWD}/projects/api-client/src/lib:/local/api-client openapitools/openapi-generator-cli generate -i http://192.168.33.10:3000/api/docs-json -g typescript-angular -o /local/api-client"
```


- openapi-generatorを起動します
    - NestJSを起動させておき、/api/docs-jsonにアクセスできる状態にしてください

```
npm run generate:api-client
```

- openapi-generatorの出力内容に合わせてexport内容を変更します

```packages/client/projects/api-client/src/public-api.ts
/*
 * Public API Surface of api-client
 */

export * from './lib/index';
```

## API呼び出し

各AngularプロジェクトでAPIを呼んでみます

### ApiModuleのインポート

`app.module.ts`で`HttpClientModule`と`ApiModule`をインポートします
`basePath`にはAPI側のURL（`/api`）を指定します。
※今回は同じサーバー上で動かすため、`host:port`は不要です。

```packages/client/projects/client1/src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HttpClientModule } from '@angular/common/http';
import { ApiModule, Configuration } from 'projects/api-client/src/lib';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,

    HttpClientModule,
    ApiModule.forRoot(() => new Configuration({basePath: '/api'})),
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
```
※client2も同様



### APIを呼んでみる


- コンポーネントで`DefaultService`をインジェクションし、onInitでAPIを呼んでみます
    - 取得した値をタイトルに追記してます

```packages/client/projects/client1/src/app/app.component.ts
import { Component, OnInit } from '@angular/core';
import { DefaultService, HelloDto } from 'projects/api-client/src/lib';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
    title = 'client1';

    constructor(private api: DefaultService) { }

    async ngOnInit() {
        const result: HelloDto = await this.api.rootGet().toPromise();
        this.title += '　' + result.message;
    }
}
```

- openapi-generatorにより、APIクライアントおよびModelクラスが生成されているため、そのまま利用することができます

※client2も同様に修正します

# 動作確認

- `http://192.168.33.10:3000/client1`にアクセス
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/2fe88284-14eb-10d4-f64a-02b0b98fad7f.png)

- `http://192.168.33.10:3000/client2`にアクセス
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/f0aef04c-c937-861d-269a-450e517b910b.png)

ちゃんとAPIで取得したメッセージが表示されています！！！


# 最後に

割と簡単な修正だけで、いい感じにプロジェクトの分割が出来ました。

クライアント側で共通のコンポーネントなんかもライブラリとして作成すると、簡単にプロジェクト間で共用できるため、結構汎用性の高い環境が出来たかなと思います。

実際にマイクロサービスで運用するにはもっといじらないとダメですが、最初からこのような構成で開発をしていればマイクロサービスへの移行も簡単に出来るかなと思います。