SlideShare a Scribd company logo
1 of 189
Download to read offline
実践 NestJS
1
誰?
後藤 歩
Ayumi Goto
@balmychan
株式会社オロ
新規事業開発チーム
プロダクトマネージャー
リードエンジニア
東京勤務 → 福岡勤務
2
触ってきたもの
言語
C++, Java, VBScript, PHP, C#, Ruby, JavaScript, TypeScript
フロントエンド開発
Flex(Flash), jQuery, AngularJS, Angular, React
アプリ開発
Android(Java), iOS(Objective-C), Capacitor
3
バックエンド開発
ASP, ASP<span>.</span>NET, Ruby on Rails, Node.js , NestJS
データベース/データストア
SQL Server, MySQL, DynamoDB, MongoDB , Redis
インフラ
オンプレミス, AWS, Render.com , Heroku
ハードウェア
Arduino, 自作ファミコン ROM 開発, 自作CPU開発
※ 強調 されているものが最近の自分のトレンドです
4
NestJS 利用歴
NestJS を使って
弊社で 2 つのプロダクト
外部で 1 つのプロダクト
の合計 3 つのプロダクトで 1 年半ほど開発をしています
5
開発中のプロダクト(弊社)
企業内の SaaS の契約情報、利用状況、シャドー IT、アカウント情
報、コスト情報を一元的に把握・管理することができる SaaS管理ツール
Google スライドやパワポ、PDF 資料を一元管理し、また、その資料
を組み合わせたり加工したりして新しいプレゼンテーションを作成・
公開できる 資料作成支援&公開ツール
https://www.preco.ink
※このスライドはMarpで作成した PDF を Preco で公開しています
6
開発中のプロダクト(外部)
非公開プロダクト
前述の 2 つと同じような構成で、クライアントサイドは React +
Capacitorの構成でハイブリットアプリ化し、iOS/Android アプリとし
て公開しています
7
今回の趣旨
NestJS を導入することで Webアプリケーションによくある機能 を容易に開発
することができました
各機能の 実装ポイント を サンプルコードを交えて 紹介したいと思います
また、サンプルコードは紙面の都合上
かなり省略している部分があり、そのままでは動きません 。あくまでもこのよう
な処理を行うイメージと思って見ていただければと思います。実際は
みなさんの環境によって若干コードも異なるので、適宜実装をしてい
ただければと思います
8
アジェンダ
NestJS を 導入する前 は...
選定理由
システム構成
コンポーネント構成
ディレクトリ構成
9
認証 の実装
認可 の実装
永続化(データベース) の実装
GraphQL API の実装
REST API の実装
バリデーション の実装
キューイング の実装
イベント処理 の実装
タスクスケジューリング の実装
データベースマイグレーション の実装
10
ファイルアップロード の実装
CDNまわり の実装
多言語対応 の実装
オフロード処理 の実装
サーバーレス起動 の実装
テスト の実装
APIドキュメンテーション の実装
デバッグ の方法
REPL の利用
11
NestJS を導入する前は...
12
NestJS を導入する前は...
当初は AWS(Lambda, AppSync, Cognito, DynamoDB,
DocumentDB)を使い サーバーレス構成 で、ピュアな Node.js に DI ラ
イブラリだけ入れて開発
CDK での AWS の管理・デプロイやリリース管理に ツラさ を感じ
る。 開発速度も上がらない
Heroku で運用することにして脱 AWS、その際に何かしらの
フレームワークの必要性 を感じたため、NestJS を導入することにしま
した。
13
選定理由
14
選定理由
1. 認証・認可といった Web アプリに必須となる機能が
公式ドキュメントで丁寧に示されており NestJS Way が踏襲しやすい(バー
ジョンアップについていくのも容易だろうと感じた)
2. Express/Fastify や Mongoose/TypeORM など、
適材適所で自分の好きな技術を選択的に組み込み やすい思想
3. ドメイン駆動設計といった 自分のやりたいアーキテクチャを反映しやすい と
感じた(元のピュアな構成+α のイメージで開発できると感じた)
4. フロントエンドもバックエンドも TypeScript にしたい
15
他に検討していたフレームワーク
16
他に検討していたフレームワーク
Ts.ED
https://tsed.io/
frourio
https://github.com/frouriojs/frourio
いずれも素晴らしいフレームワークと思いますが、NestJS は
ドキュメントやサードパーティライブラリが充実 していると感じた点、
設計思想の適用がしやすい点 を重視して、NestJS にしました
17
システム構成
18
19
名前 管理
フロントエン
ドサーバー
Netlify でホスティング
API サーバー Heroku 上で並列で起動し API を提供する
バッチ処理
高負荷な処理は Lambda 上で起動した NestJS を呼
び出して処理
ファイル S3 と CloudFront で管理・配信
CDN
CloudFront で配信。API サーバーで署名付き URL
を生成してその URL からアクセス
データベース MongoDB Atlas でホスティング
キャッシュ Redis Enterprise でホスティング 20
コンポーネント構成
21
22
プレゼンテーション層
外部からのアクセスとアプリケーション層との間のデータの受け渡し
を行う。外部からのアクセスは GraphQL や REST といった形式の他、
JSON やフォーム形式などデータのフォーマットなどもある
名前 役割
リゾルバー GraphQL API のエンドポイント
コントローラー REST API のエンドポイント
サブスクリプション GraphQL のサブスクリプション
23
ユースケース層
各ユースケースを実装する。データの取得、認可チェック、ドメイン
オブジェクトの追加/削除/操作の呼び出し、永続化処理などを実装
する
24
名前 役割
ユースケース ユースケースの実装
サービス
ユースケース共通の機能や外部公開するサービス
の実装
サブスクライバ
ー
イベントが発生した場合の処理の実装
タスク 毎日実行する定期処理など
プロセッサー キューイングされたデータの処理
25
ドメイン層
対象とする業務ドメインのドメインオブジェクトの定義やビジネスル
ール・ロジックの実装を行う
名前 役割
エンティティ ドメインオブジェクトの仕様やルール、操作の実装
ドメインサー
ビス
複数のドメインオブジェクトをまたがる仕様やルー
ル、操作の実装
26
インフラ層
データ取得や永続化処理の中継を行う
自前で実装することはなく、通常 TypeORM などの O/R マッパーと、
@nestjs/typeorm が提供するリポジトリのラッパーをそのまま使いま
す
27
ディレクトリ構成
28
├─.circleci CircleCIの設定
├─.github GitHubの設定
├─.vscode vscodeの設定
├─dist ビルド結果出力
│ └─schema.graphql 自動生成されたschema.graphql
├─e2e E2Eテスト関連
│ ├─*** E2Eテストファイル群
│ └─jest.setup.ts Jestセットアップ処理
├─src
│ ├─@types グローバル型定義群
│ ├─common
│ │ ├─controllers 共通コントローラー群
│ │ ├─decorators 共通デコレータ群
│ │ ├─guards 共通ガード群
│ │ ├─exceptions 共通例外群
│ │ ├─interceptors 共通インターセプター群
│ │ ├─models 共通モデル群
│ │ ├─resolvers 共通リゾルバー群
│ │ ├─types 共通型定義群
│ │ ├─utils 共通ユーティリティ関数群
│ │ └─validators 共通バリデーター群
│ ├─entities エンティティ群
│ ├─events イベント群
│ ├─migrations マイグレーション群
│ ├─models モデル群
│ ├─modules モジュール群 - 各種モジュール・サブモジュール
│ │ └─***
│ │ ├─config 設定群
│ │ ├─domain-services ドメインサービス群
│ │ ├─entities モジュール内で利用するエンティティ群
│ │ ├─events イベント群
│ │ ├─mails メール群
│ │ ├─models モデル群
│ │ ├─processors キュー処理群
│ │ ├─queues キュー群
│ │ ├─resolvers リゾルバー群
│ │ ├─services サービス群
│ │ ├─subscribers サブスクライバー群
│ │ ├─tasks タスク群
│ │ ├─usecases ユースケース群
│ │ ├─utils モジュールごとのユーティリティ関数
│ │ └─*.module.ts モジュール定義
│ ├─app.module.ts メインモジュール
│ └─main.ts エントリーファイル
├─.env 環境変数ファイル
├─.env.local ローカル環境変数ファイル
├─.eslintrc.js ESLint設定ファイル
├─.gitignore gitignore
├─.prettierrc Prettier設定ファイル
├─.versionrc standard-version設定ファイル
├─CHANGELOG.md CHANGELOG(standard-versionで生成)
├─coverageconfig.json tscodecover設定ファイル
├─jest.config.js Jest設定ファイル
├─nest-cli.json NestJS用の設定ファイル
├─package-lock.json package.jsonのロックファイル
├─package.json パッケージファイル
├─Procfile Heroku起動設定ファイル
├─README.md README
├─tsconfig.build.json NestJSビルド時のTypeScriptの設定
└─tsconfig.json TypeScriptの設定
29
認証の実装
30
認証の実装
公式ドキュメントのAuthenticationを参考に実装しましょう
認証の実装ポイントは 3 つです
1. Passport 用の ストラテジー を作成
2. 作成したストラテジーを使った ガード を作成
3. コントローラーやリゾルバーにガードを組み込む
31
認証の実装
JWT 認証
SAML 認証
を例に実装例を紹介します
32
JWT 認証の実装
33
Passport 用のストラテジーを作成
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService, configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get("jwt.accessToken.secret"),
});
}
async validate(payload: AccessTokenPayload): Promise<UserContext> {
const user = await this.authService.validateAccessTokenPayload(payload);
if (!user) {
throw new UnauthorizedException();
}
// ユーザーコンテキスト(セッション情報と同じようなもの)を生成して返す
return UserContext.createBy({
...user,
originUserId: payload.originUserId,
});
}
}
34
// アクセストークンのペイロードを認証してユーザーを得る
async validateAccessTokenPayload(
props: AccessTokenPayload
): Promise<User | undefined> {
const user = await findOne(this.userRepository, {
id: props.userId,
});
return user;
}
35
作成したストラテジーを使ったガードを作成
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
getRequest(context: ExecutionContext): any {
const request = getRequestByExecutionContext(context);
return request;
}
handleRequest(err: any, user: any, info: any): any {
if (err || !user) {
throw err || new UnauthorizedException();
}
return user;
}
}
36
コントローラーやリゾルバーにガードを組み込む
@Resolver(() => User)
@UseGuards(JwtAuthGuard)
export class UserResolver {
constructor(private getUsersUsecase: GetUsersUsecase) {
super();
}
@Query(() => PaginatedUser)
async users(
@Args() props: GetUsersArgs,
@CurrentUserContext() ctx: UserContext
): Promise<PaginatedUser> {
return this.getUsersUsecase.execute(props, ctx);
}
}
37
ログイン
async signIn(
props: SignInInput,
ctx: SignInContext
): Promise<SignInResponse> {
const user = await findOne(this.userRepository, {
id: props.userId,
});
if (!user) {
throw new UnauthorizedException();
}
if (!user.verifyPassword(props.password)) {
throw new UnauthorizedException();
}
const accessToken = this.generateAccessToken({
userId: user.id,
});
const refreshToken = this.generateRefreshToken({
userId: user.id,
refreshTokenId: refreshTokenEntity.id,
});
return {
token: {
accessToken,
refreshToken,
},
};
38
アクセストークン、リフレッシュトークンの生成
public generateAccessToken(
payload: AccessTokenPayload,
opts?: {
expiresIn?: string;
}
): string {
return this.jwtService.sign(payload, {
secret: this.configService.get('jwt.accessToken.secret'),
expiresIn:
opts?.expiresIn ?? this.configService.get('jwt.accessToken.expiresIn'),
subject: payload.userId,
});
}
private generateRefreshToken(payload: RefreshTokenPayload): string {
return this.jwtService.sign(payload, {
secret: this.configService.get('jwt.refreshToken.secret'),
expiresIn: this.configService.get('jwt.refreshToken.expiresIn'),
subject: payload.userId,
});
}
39
リフレッシュ
async refresh(props: RefreshInput, ctx: RefreshContext): Promise<AuthToken> {
const { refreshToken } = props;
const payload = this.verifyRefreshToken(refreshToken);
const user = await findOne(this.userRepository, {
id: payload.userId,
});
if (!user) {
throw new InternalServerErrorException('ユーザーが存在しません');
}
const accessToken = this.generateAccessToken({
userId: payload.userId,
});
return {
accessToken,
};
}
40
SAML 認証の実装
41
passport-saml をインストール
$ npm i passport-saml
42
SAML 用のストラテジーを作成
@Injectable()
export class SamlStrategy extends PassportStrategy(MultiSamlStrategy, "saml") {
constructor(
@InjectRepository(Organization)
private readonly organizationRepository: MongoRepository<Organization>,
@InjectRepository(User)
private readonly userRepository: MongoRepository<User>
) {
super(
{
passReqToCallback: true,
43
getSamlOptions: async (
req: {
params: {
organizationId?: OrganizationId;
};
},
done: SamlOptionsCallback
) => {
// SAML関連の設定情報の取得と設定(省略)
done(null, {
cert: organization.ssoConfig.cert,
audience: organizationId,
});
},
},
44
async (
req: {
params: {
organizationId?: OrganizationId;
};
},
profile: {
nameID: string;
},
done: VerifiedCallback
) => {
const { organizationId } = req.params;
const { nameID } = profile;
if (!organizationId) {
done(new UnauthorizedException());
}
45
const user = await findOne(this.userRepository, {
organizationId,
email: nameID,
});
if (!user) {
done(new UnauthorizedException());
return;
}
done(null, {
...user,
});
}
);
}
}
46
SAML 用のストラテジーを使ったガードを作成
saml-auth.guard.ts
@Injectable()
export class SamlAuthGuard extends AuthGuard("saml") {
handleRequest(err: any, user: any, info: any): any {
if (err || !user) {
throw err || new UnauthorizedException();
}
return user;
}
}
47
SAML 用のガードをコントローラーに組み込む(SP Initiated)
@Controller("/api/saml")
export class SamlController {
constructor(
private readonly signInByUserUsecase: SignInByUserUsecase,
private readonly getSamlTargetUrlUsecase: GetSamlTargetUrlUsecase,
private readonly configService: ConfigService
) {}
@Post("/:organizationId/signIn")
@UseGuards(SamlAuthGuard)
async signIn(
@SamlUserContext() ctx: UserContext,
@Ip() ip: string
): Promise<SignInResponse> {
return this.signInByUserUsecase.execute(
{
userId: ctx.id,
},
{
...ctx,
ip,
}
);
}
48
IdP Initiated
@Post("/:organizationId/acs")
@Redirect()
async acs(
@Body() props: { SAMLResponse: string; RelayState: string },
@Param("organizationId") organizationId: OrganizationId
): Promise<{
url: string;
statusCode: number;
}> {
const baseUrl = this.configService.get("auth.baseUrl");
return {
url: `${baseUrl}/o/${organizationId}/saml?SAMLResponse=${encodeURIComponent(
props.SAMLResponse
)}`,
statusCode: 302,
};
}
} 49
認可の実装
50
認可の実装
公式ドキュメントのAuthorizationを参考に実装しましょう
認可制御は大きく 2 つあります
1. ロールベース で API のエンドポイントごとに認可制御する
2. リソースベース でデータ一つ一つに対して認可制御する
51
ロールベースの実装
52
ロールベースの実装
ロールベースの実装ポイントは 2 つです
1. ユーザーに対して ロール を追加する
2. ロールによるチェックを行う ガード を作成する
3. ロールによるガードをコントローラーかリゾルバーに組み込む
53
ユーザーに対して ロール を追加する
export enum Role {
Admin = "Admin",
User = "User",
}
registerEnumType(Role, {
name: "Role",
});
54
@Entity()
@ObjectType()
export class User extends BaseEntity<User> {
static new(props: NewProps<User>): User {
return new User(props);
}
@Index()
@Column()
@Field(() => ID)
id: UserId;
@Index()
@Column()
@Field(() => [Role])
roles: Role[];
}
55
2) ロールによるチェックを行う ガード を作成する
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return false;
}
const user = getUserByExecutionContext(context);
return requiredRoles.some((role) => user?.roles?.includes(role));
}
}
56
3) ロールによるガードをコントローラーかリゾルバーに組み込む
@Roles(Role.User)
@Resolver(() => User)
export class UserResolver extends BaseAuthResolver {
constructor(
private readonly getUserUsecase: GetUserUsecase,
private readonly getUsersUsecase: GetUsersUsecase,
) {
super();
}
// こちらのユーザー取得はBaseAuthResolverの @Roles(Role.User) が効いている
@Query(() => User)
async user(
@Args('id', { type: () => ID }) id: UserId,
@CurrentUserContext() ctx: UserContext
): Promise<User> {
return this.getUserUsecase.execute(id, ctx);
} 57
// こちらのユーザー一覧は @Roles(Role.Admin) が効いている
@Query(() => PaginatedUser)
@Roles(Role.Admin)
async users(
@Args() props: GetUsersArgs,
@CurrentUserContext() ctx: UserContext
): Promise<PaginatedUser> {
return this.getUsersUsecase.execute(props, ctx);
}
}
58
リソースベースの実装
59
リソースベースの実装
リソースベースの実装ポイントは 2 つ+1 つです
1. CaslService を作成する
2. CaslService を使った認可チェックをユースケース内で行う
3. (Optional)Interceptor を使って API が返すデータを再度チェックす
る
60
CaslService を作成する
@Injectable()
export class CaslService {
for(user: User | UserContext) {
const { can, cannot, build } = new AbilityBuilder<
Ability<[Action, Subjects]>
>(Ability as AbilityClass<AppAbility>);
if (user.roles.includes(Role.user)) {
can(Action.Read, User, { organizationId: user.organizationId });
can(Action.Update, User, {
id: user.id,
roles: { $nin: [Role.Admin] },
});
}
if (user.roles.includes(Role.UserAdmin)) {
can(Action.Manage, "all");
}
const ability = build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
return ability;
}
}
61
can(
ctx: UserContext,
action: Action,
subject:
| Subjects
| Subjects[]
| Paginated<Subjects>
| unknown
): boolean {
const ability = this.for(ctx);
if (Array.isArray(subject)) {
return subject.every((v) => ability.can(action, v));
} else if (
typeof subject === 'object' && 'pagination' in (subject as any)
) {
return (subject as Paginated<any>).data.every((v) => ability.can(action, v));
} else if (typeof subject === 'object') {
return ability.can(action, subject as Subjects);
} else {
return true;
}
}
62
CaslService を使った認可チェックをユースケース内で行う
// ユーザー取得のユースケース
@Injectable()
export class GetUserUsecase {
constructor(
private readonly caslService: CaslService,
@InjectRepository(User)
private readonly userRepository: MongoRepository<User>
) {}
async execute(id: UserId, ctx: UserContext): Promise<User> {
const user = await findOne(this.userRepository, { id });
if (!user) {
throw new NotFoundException();
}
if (this.caslService.cannot(ctx, Action.Read, user)) {
throw new ForbiddenException();
}
return user;
}
}
63
// ユーザー一覧のユースケース
@Injectable()
export class GetUsersUsecase {
constructor(
private readonly caslService: CaslService,
@InjectRepository(User)
private readonly userRepository: MongoRepository<User>
) {}
async execute(props: GetUsersArgs, ctx: UserContext): Promise<PaginatedUser> {
const data = await paginate(this.userRepository, props, {
...(props.organizationId
? {
organizationId: props.organizationId,
}
: {}),
});
if (this.caslService.cannot(ctx, Action.Read, data)) {
throw new ForbiddenException();
}
return data;
}
}
64
(Optional)Interceptor を使って API が返すデータを再度チェックす
る
/**
* CASLによるリソースベースの権限チェック
* ※サービス内でもチェックしているが、念の為最後のレスポンス時点でもチェックする
*/
@Injectable()
export class CaslInterceptor implements NestInterceptor {
constructor(private readonly caslService: CaslService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
tap((data) => {
const user = getUserByExecutionContext(context);
if (!user) {
return;
}
const ctx = UserContext.createBy(user);
if (this.caslService.cannot(ctx, Action.Read, data)) {
throw new ForbiddenException();
}
})
);
65
@UseInterceptors(CaslInterceptor)
export class BaseResolver {}
66
永続化(データベース)の実装
67
永続化(データベース)の実装
公式ドキュメントのDatabaseとMongoを参考に実装しましょう
弊社では MongoDB + TypeORM 組み合わせて実装を行なっており、
また、 コードファースト でスキーマ定義を行なっています
永続化(データベース)の実装ポイントは 2 つです
1. エンティティの定義
2. エンティティの生成 と リポジトリ を通した 永続化
68
エンティティの定義
@Entity()
export class User {
static new(props: NewProps<User>): User {
return new User(props);
}
@Index()
@Column()
id: UserId;
@Index()
@Column()
name: string;
@Index()
@Column()
email: string;
}
69
エンティティの生成 と リポジトリ を通した 永続化
@Injectable()
export class CreateUserUsecase {
constructor(
private readonly caslService: CaslService,
@InjectRepository(User)
private readonly userRepository: MongoRepository<User>
) {}
async execute(props: CreateUserInput, ctx: UserContext): Promise<User> {
const user = User.new(props);
if (this.caslService.cannot(ctx, Action.Create, user)) {
throw new ForbiddenException();
}
await bulkSave(this.userRepository, user, ctx);
return user;
}
}
70
GraphQL API の実装
71
GraphQL API の実装
公式ドキュメントのGraphQLを参考に実装しましょう。弊社では
GraphqQL のスキーマ定義はコードファーストで行なっています
GraphQL API の実装ポイントは 3 つ+2 つです
1. エンティティに GraphQL関連のデコレーター を付ける
2. 更新系の場合はパラメーターを実装する
3. リゾルバー を実装する
4. (Extra)クライアントサイドでコード生成
5. (Extra)ResolveField は N+1 問題を避けるために dataloader を導入
72
エンティティに GraphQL関連のデコレーター を付ける
@ObjectType()
export class User extends BaseEntity<User> {
static new(props: NewProps<User>): User {
return new User(props);
}
@Field(() => ID)
id: UserId;
@Field(() => GraphQLString)
name: string;
@Field(() => GraphQLString)
email: string;
@Field(() => ID)
organizationId: OrganizationId;
}
73
更新系の場合はパラメーターを実装する
@InputType()
export class UpdateUserInput {
@Field(() => ID)
id: UserId;
@Field(() => GraphQLString, { nullable: true })
name?: string;
@Field(() => GraphQLString, { nullable: true })
email?: string;
}
74
リゾルバー を実装する
@Resolver(() => User)
export class UserResolver extends BaseAuthResolver {
constructor(
private readonly getUsersUsecase: GetUsersUsecase,
private readonly getUserUsecase: GetUserUsecase,
private readonly updateUserUsecase: UpdateUserUsecase,
private readonly referenceService: ReferenceService
) {
super();
}
@Query(() => PaginatedUser)
async users(
@Args() props: GetUsersArgs,
@CurrentUserContext() ctx: UserContext
): Promise<PaginatedUser> {
return this.getUsersUsecase.execute(props, ctx);
}
@Query(() => User)
async user(
@Args('id', { type: () => ID }) id: UserId,
@CurrentUserContext() ctx: UserContext
): Promise<User> {
return this.getUserUsecase.execute(id, ctx);
} 75
@Mutation(() => GraphQLBoolean)
async updateUser(
@Args('props') props: UpdateUserInput,
@CurrentUserContext() ctx: UserContext
): Promise<boolean> {
return this.updateUserUsecase.execute(props, ctx);
}
@ResolveField(() => Organization, { nullable: true })
async organization(
@Parent() user: User,
@CurrentUserContext() ctx: UserContext
): Promise<Organization | undefined> {
return this.referenceService.getOrganization(user.organizationId, ctx);
}
}
76
(Extra)クライアントサイドでコード生成
GraphQL Code Generatorで 型安全 に GraphQL を呼び出す React 用の
カスタムフックを自動生成 することができます
77
config.yml
overwrite: true
schema: 'http://localhost:4000/graphql'
documents:
- ./src/graphql/*.graphql
generates:
./src/graphql/generated.ts:
plugins:
- 'typescript'
- 'typescript-operations'
- 'typescript-react-apollo'
config:
skipTypename: false
enumsAsTypes: true
hooks:
afterAllFileWrite:
- npm run lint
78
user.graphql
query user($id: ID!) {
user(id: $id) {
id
name
email
organizationId
organization {
name
}
}
}
mutation updateUser($props: UpdateUserInput!) {
updateUser(props: $props)
}
79
package.json
"scripts": {
"codegen": "graphql-codegen --config codegen.yml",
}
$ npm run codegen
80
generated.ts
export function useUserQuery(
baseOptions: Apollo.QueryHookOptions<UserQuery, UserQueryVariables>
) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useQuery<UserQuery, UserQueryVariables>(UserDocument, options);
}
export function useUserLazyQuery(
baseOptions?: Apollo.LazyQueryHookOptions<UserQuery, UserQueryVariables>
) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useLazyQuery<UserQuery, UserQueryVariables>(
UserDocument,
options
);
}
81
UserDetailPage.tsx
function UserDetailPage() {
const { data: { user } = {}, loading: userLoading } = useUserQuery({
variables: {
id: userId,
},
});
return <>{JSON.strinfigy(user)}</>;
}
82
(Extra)ResolveField は N+1 問題を避けるためにdataloaderを導入
一定期間内の取得処理を束ねてひとつの問い合わせで取得してくれる
仕組み
function createIdDataLoader<T extends { id: string }>(
repository: MongoRepository<T>
) {
return new DataLoader(async (keys: readonly unknown[]) => {
const ids = [...keys] as string[];
const items = await find(repository, {
id: { $in: ids.map((v) => v) },
});
const itemsIdMap = arrayToMap(items, (v) => v.id);
return ids.map((id) => itemsIdMap.get(id));
});
}
83
reference.service.ts
@Injectable()
export class ReferenceService {
constructor(
private readonly caslService: CaslService,
@InjectRepository(Organization)
private readonly organizationRepository: MongoRepository<Organization>
) {}
private organizationLoader = createIdDataLoader(this.organizationRepository);
async getOrganization(
id: OrganizationId,
ctx: UserContext
): Promise<Organization | undefined> {
const data = await this.organizationLoader.load(id);
if (!data || this.caslService.cannot(ctx, Action.Read, data)) {
return;
}
return data;
}
}
84
@ResolveField(() => Organization, { nullable: true })
async organization(
@Parent() user: User,
@CurrentUserContext() ctx: UserContext
): Promise<Organization | undefined> {
return this.referenceService.getOrganization(user.organizationId, ctx);
}
85
REST API の実装
86
REST API の実装
公式ドキュメントのControllersを参考に実装しましょう
REST API の実装ポイントは 1 つです
1. コントローラー を実装する
87
@Controller("api/users")
export class UserController extends BaseApiController {
constructor(
private readonly getUserUsecase: GetUserUsecase,
private readonly updateUserUsecase: UpdateUserUsecase
) {
super();
}
@Get("/:userId")
async user(
@Param("userId")
userId: UserId,
@CurrentUserContext() ctx: UserContext
): Promise<User> {
return this.getUserUsecase.execute(userId, ctx);
}
@Put("/:userId")
async updateUser(
@Body()
props: UpdateUserInput,
@CurrentUserContext() ctx: UserContext
): Promise<boolean> {
return this.updateUserUsecase.execute(props, ctx);
}
}
88
バリデーションの実装
89
バリデーションの実装
公式ドキュメントのValidationを参考に実装しましょう
バリデーションの実装ポイントは 2 つです
1. グローバルパイプ に ValidationPipe を追加する
2. 入力系のオブジェクトに バリデーション用のデコレーター を記載する
90
グローバルパイプ に ValidationPipe を追加する
export async function bootstrap() {
const fastifyInstance = fastify.fastify();
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(fastifyInstance)
);
app.useGlobalPipes(new ValidationPipe());
await app.listen(process.env.PORT ?? 4000, "0.0.0.0");
}
91
入力系のオブジェクトに バリデーション用のデコレーター を記載する
@InputType()
export class UpdateUserInput {
@Field(() => ID)
id: UserId;
@MaxLength(100)
@Field(() => GraphQLString, { nullable: true })
name?: string;
@MaxLength(100)
@IsEmail()
@Field(() => GraphQLString, { nullable: true })
email?: string;
}
92
キューイングの実装
93
キューイングの実装
公式ドキュメントのQueuesを参考に実装しましょう
キューイングの実装ポイントは5つです
1. Redis を導入する
2. BullModule を導入する
3. キュー を追加する
4. キューを処理する プロセッサー を追加する
5. キューにデータを投入する
94
1) Redis を導入する
各々好きに Redis を導入してください
Docker を使う場合は下記のような docker-compose.yml を用意します
version: "3"
services:
mongo:
image: mongo:5.0
ports:
- "27017:27017"
redis:
image: redis
ports:
- "6379:6379"
95
もしくは開発時は redis-memory-server もおすすめです
export async function launchLocalRedis(): Promise<void> {
new RedisMemoryServer({
instance: {
ip: "127.0.0.1",
port: 6379,
},
autoStart: true,
});
}
96
scripts/db-start.ts
import { launchLocalRedis } from "../src/common/utils/createRedis";
(async function () {
await launchLocalRedis();
})();
package.json
"scripts": {
"start": "run-p db:start start:dev",
"start:dev": "nest start --watch",
"db:start": "ts-node scripts/db-start",
}
97
BullModule を導入する
@Module({
imports: [
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
redis: {
host: configService.get<string>("REDIS_HOST"),
port: configService.get<number>("REDIS_PORT"),
username: configService.get<string>("REDIS_USER"),
password: configService.get<string>("REDIS_PASSWORD"),
},
defaultJobOptions: {
removeOnFail: false,
removeOnComplete: false,
},
};
},
}),
],
controllers: [],
providers: [],
})
export class AppModule {}
98
キュー を追加する
modules/account/queues/index.ts
import { BullModule } from "@nestjs/bull";
export const QueueName = {
SyncCosts: "account::sync-accounts",
} as { [name: string]: string };
export default Object.values(QueueName).map((name) =>
BullModule.registerQueue({
name,
})
);
99
modules/account/account.module.ts
import queues from "./queues";
@Module({
imports: [...queues],
controllers: [],
providers: [],
})
export class AccountModule {}
100
キューを処理する プロセッサー を追加する
@Processor(QueueName.SyncAccounts)
export class SyncAccountProcessor {
constructor(private readonly syncAccountUsecase: SyncAccountsUsecase) {}
@Process()
async process(job: Job): Promise<boolean> {
const { props, ctx } = parseJobData<SyncAccountsInput>(job.data);
this.syncAccountUsecase.execute(props, ctx);
}
}
101
modules/account/account.module.ts
import queues from "./queues";
import processors from "./processors";
@Module({
imports: [...queues],
controllers: [],
providers: [...processors],
})
export class AccountModule {}
102
キューにデータを投入する
@Injectable()
export class TriggerSyncAccountsUsecase {
constructor(
@InjectQueue(QueueName.SyncAccounts)
private readonly syncAccountsQueue: Queue<QueueProps<SyncAccountsInput>>
) {}
async execute(props: SyncAccountsInput, ctx: UserContext): Promise<JobId> {
const job = await this.syncAccountsQueue.add({ props, ctx });
return job.id;
}
}
103
イベント処理の実装
104
イベント処理の実装
公式ドキュメントのEventsを参考に実装しましょう
※弊社では主にモジュール間の連携に使っています
イベントの実装ポイントは5つです
1. イベントクラスを作成する
2. ユースケース内でイベントを発火する
3. サブスクライバーでイベントを処理する
105
イベントクラスを作成する
export class PresentationCreatedEvent extends BaseContextableEvent<PresentationCreatedEvent> {
static EventName = "presentation.created";
static new(
props: NewProps<PresentationCreatedEvent>
): PresentationCreatedEvent {
return new PresentationCreatedEvent(props);
}
/**
* プレゼンテーション
*/
presentation: Presentation;
}
106
ユースケース内でイベントを発火する
@Injectable()
export class CreatePresentationUsecase {
constructor(
private readonly caslService: CaslService,
private readonly eventEmitter: EventEmitter2,
@InjectRepository(Presentation)
private readonly presentationRepository: MongoRepository<Presentation>
) {}
async execute(
props: CreatePresentationInput,
ctx: UserContext
): Promise<Presentation> {
// 作成処理(省略)
await this.eventEmitter.emitAsync(
PresentationCreatedEvent.EventName,
PresentationCreatedEvent.new({
presentation,
ctx,
})
);
return presentation;
}
}
107
サブスクライバーでイベントを処理する
/**
* プレゼンテーションが追加されたらチームのプレゼン数を更新する
*/
@Injectable()
export class PresentationCreatedEventSubscriber extends UpdateNumberOfPresentationsSubscriber {
@OnEvent(PresentationCreatedEvent.EventName)
@CatchEventHandleError()
async handle(event: PresentationCreatedEvent): Promise<void> {
const { teamId, ctx } = event;
// プレゼン数を取得
const numberOfPresentations = await count(this.presentationRepository, {
teamId,
});
// 保存
await bulkPatch(
this.teamRepository,
{
id: teamId,
numberOfPresentations,
},
ctx
);
}
}
108
export function CatchEventHandleError() {
return function (target: any, propertyName: any, descriptor: any) {
const method = descriptor.value;
descriptor.value = async function (...args: any) {
try {
return await method.apply(this, args);
} catch (e) {
console.error(e);
}
};
};
}
109
タスクスケジューリングの実装
110
タスクスケジューリングの実装
公式ドキュメントのTask Schedulingを参考に実装しましょう
タスクスケジューリングの実装ポイントは 1 つです
1. タスクサービスを用意しスケジューリングしたい処理を実装する
111
タスクサービスを用意しスケジューリングしたい処理を実装する
modules/account/tasks/sync-account.task.ts
@Injectable()
export class SyncAccountTask {
constructor(
private readonly syncAccountsUsecase: SyncAccountsUsecase,
@InjectRepository(Integration)
private readonly integrationRepository: MongoRepository<Integration>
) {}
@WorkerCron(CronExpression.EVERY_DAY_AT_4PM) // 日本時間深夜1時
async syncAccounts(): Promise<void> {
const ctx = UserContext.createForTask();
this.syncAccountsUsecase.execute(props, ctx);
}
} 112
modules/account/account.module.ts
import tasks from "./tasks";
@Module({
imports: [],
controllers: [],
providers: [...tasks],
})
export class AccountModule {}
113
データベースマイグレーションの実装
114
データベースマイグレーションの実装
公式ドキュメントのDatabase Migrationsを参考に実装しましょう
※ただし、この項は単に TypeORMのドキュメントに従うように としか記載が
ありません
データベースマイグレーションの実装ポイントは 2 つです
1. TypeOrmModule に マイグレーションの設定 を行う
2. マイグレーションファイル を追加する
115
TypeOrmModule に マイグレーションの設定 を行う
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
// 省略
migrations: isProduction
? ["dist/migrations/**/*{.ts,.js}"]
: undefined,
migrationsRun: isProduction,
};
},
}),
],
controllers: [],
providers: [],
})
export class AppModule {}
116
マイグレーションファイル を追加する
"scripts": {
"migration:create": "typeorm migration:create",
}
$ npm run migration:create src/migrations/UpdateUserName
117
src/migrations/1647998118175-UpdateUserName.ts
export class UpdateUserName1647998118175 implements MigrationInterface {
public async up(queryRunner: MongoQueryRunner): Promise<void> {
await queryRunner.manager.updateMany(
User,
{},
{ $set: { name: "Updated" } }
);
}
public async down(): Promise<void> {}
}
118
ファイルアップロードの実装
119
データベースマイグレーションの実装
Nestjs - file upload with fastify multipartを参考に実装しましょう
(fastify の例になります)
また、GraphQL の API でファイルアップロードを実現するには
Base64 エンコードした状態で送る必要があり、ファイルサイズが増
大し現実的ではないため、REST API を使います
ファイルアップロードの実装ポイントは 2 つです
1. UploadGuard を作成する
2. FileController を作成する
120
UploadGuard を作成する
@Injectable()
export class UploadGuard implements CanActivate {
public async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest() as CustomFastifyRequest;
const isMultipart = req.isMultipart();
if (!isMultipart) {
throw new BadRequestException("multipart/form-data expected.");
}
const file = await req.file();
if (!file) {
throw new BadRequestException("file expected");
}
req.incomingFile = file;
return true;
}
}
121
import { FastifyRequest } from "fastify";
export type CustomFastifyRequest = FastifyRequest & {
incomingFile: Storage.MultipartFile;
};
122
FileController を作成する
@Controller("api/files")
export class FileController extends BaseAuthController {
constructor(private readonly fileService: FileService) {
super();
}
@Post()
@UseGuards(UploadGuard)
async upload(@File() props: Storage.MultipartFile): Promise<string> {
return await this.fileService.uploadByMultipartFile(props);
}
}
123
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { CustomFastifyRequest } from "src/models/custom-fastify-request";
export const File = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest() as CustomFastifyRequest;
const file = req.incomingFile;
return file;
}
);
124
@Injectable()
export class FileService {
private readonly s3: S3;
constructor(private readonly configService: ConfigService) {
this.s3 = new S3({
region: configService.get('s3.region'),
credentials: {
accessKeyId: configService.get('s3.accessKeyId'),
secretAccessKey: configService.get('s3.secretAccessKey'),
},
});
}
125
async upload(props: Storage.MultipartFile): Promise<void> {
const { filename, mimetype } = props;
const body = await props.toBuffer();
const ext = path.extname(filename);
const id = generateUuid();
const key = `upload/${id}${ext}`;
await this.s3.putObject({
Bucket: 'example',
Key: key,
Body: body,
ContentType: mimetype,
});
}
}
126
CDN まわりの実装
127
CDN まわりの実装
アップロードしたファイルを配信する際、API サーバーを経由せずに
Amazon CloudFront といった CDN から配信したくなると思います。
その際気になるのが
CDNで配信したら権限チェックなく誰でも見れるようになってしまうのでは? です
弊社では@ResolveField で URL を有効期限を短くした署名付き URLに
変えることで
APIサーバーで権限チェックしてURLを発行し、そのURLでファイルにアクセスさせる と
いうことができるようになります。
128
URL 例
通常
https://xxxxxxx.cloudfront.net/upload/hoge.png
署名付き
https://xxxxxxx.cloudfront.net/upload/hoge.png?
Expires=1664245216&Key-Pair-
Id=K1QM8EK00QGWTG&Signature=xxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxx
129
CDN まわりの実装ポイントは 2 つです
1. CloudFront で署名付き URL のみ許可するように設定する
2. URL を署名して返す
130
CloudFront で署名付き URL のみ許可するように設定する
[ビヘイビア]->[編集]
131
URL を署名して返す
import { CloudFront } from "aws-sdk";
@Injectable()
export class FileService {
private readonly signer: CloudFront.Signer;
constructor(private readonly configService: ConfigService) {
this.signer = new CloudFront.Signer(
this.configService.get("cf.keypairId"),
this.configService.get("cf.privateKey")
);
}
// 署名付きURLを生成して返す
generateSignedUrl(url: string, ctx: { now: Date }): string {
return this.signer.getSignedUrl({
url,
expires: getUnixTime(addMinutes(ctx.now, 120)), // 有効期限は2時間
});
}
}
132
user.entity.ts
@ObjectType()
export class User extends BaseEntity<User> {
static new(props: NewProps<User>): User {
return new User(props);
}
@Field(() => ID)
id: UserId;
@Field(() => GraphQLString)
name: string;
// 画像フィールドを追加
@Field(() => GraphQLString)
image?: string;
}
133
user.resolver.ts
@Resolver(() => User)
export class UserResolver extends BaseAuthResolver {
constructor() {
super();
}
@ResolveField(() => GraphqlString, { nullable: true })
async image(
@Parent() parent: User,
@CurrentUserContext() ctx: UserContext
): Promise<string | undefined> {
if (!parent.image) {
return;
}
return this.fileService.generateSignedUrl(parent.image, ctx);
}
}
134
多言語対応の実装
135
多言語対応の実装ポイントは 3 つです
1. i18n の導入
2. I18nService の作成
3. I18nService の利用
136
i18n の導入
多言語対応ライブラリはいくつかありますがi18n-nodeを導入し、こ
れをラップしたモジュール、サービスを作成して使っています
$ npm i i18n
$ npm i --save-dev @types/i18n
※ちなみにバックエンド側の多言語対応の主な用途はエラーメッセー
ジです
137
I18nServce の作成
@Injectable()
export class I18nService {
private readonly i18n: I18n;
constructor() {
// Lambda対応はパス解決のために別途対応が必要ですが割愛
this.i18n = new I18n();
this.i18n.configure({
locales: ['en', 'ja'],
directory: path.join(__dirname, 'locales')
updateFiles: false,
});
}
t(phrase: string, ctx: { locale?: 'ja' | 'en' }): string {
const { locale } = ctx;
return this.i18n.__({
phrase,
locale,
});
}
}
138
ja.json
{
"ユーザーが見つかりません": "ユーザーが見つかりません",
"ユーザーを閲覧する権限がありません": "ユーザーを閲覧する権限がありません"
}
en.json
{
"ユーザーが見つかりません": "User not found",
"ユーザーを閲覧する権限がありません": "You can not read users"
}
139
I18nService の利用
@Injectable()
export class GetUserUsecase {
constructor(
private readonly caslService: CaslService,
private readonly i18n: I18nService,
@InjectRepository(User)
private readonly userRepository: MongoRepository<User>
) {}
async execute(id: UserId, ctx: UserContext): Promise<User> {
const user = await findOne(this.userRepository, { id });
if (!user) {
throw new NotFoundException(this.i18n.t("ユーザーが見つかりません", ctx));
}
if (this.caslService.cannot(ctx, Action.Read, user)) {
throw new ForbiddenException(
this.i18n.t("ユーザーを閲覧する権限がありません", ctx)
);
}
return user;
}
}
140
サーバーレス起動の実装
141
サーバーレス起動の実装
公式ドキュメントのServerlessを参考に実装しましょう
また、弊社では Serverless Framework を使って Lambda をデプロイ
する運用にしています
サーバーレス起動の実装ポイントは 3 つです
1. aws-serverless-fastify/express を導入し起動関数を調整
2. Lambda のハンドラーを用意する
3. (Optional)serverless framework でデプロイ
142
aws-serverless-fastify/express を導入し起動関数を調整
$ npm i aws-serverless-fastify
export async function bootstrap() {
const fastifyInstance = fastify.fastify();
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(fastifyInstance)
);
await app.listen(process.env.PORT ?? 4000, "0.0.0.0");
// Fastifyのインスタンスも返すようにする
return {
app,
fastifyInstance,
};
}
143
Lambda のハンドラーを用意する handlers/api/handler.ts
import {
Context,
APIGatewayProxyEvent,
APIGatewayProxyResult,
} from "aws-lambda";
import * as fastify from "fastify";
import { proxy } from "aws-serverless-fastify";
import { bootstrap } from "src/bootstrap";
let fastifyInstance: fastify.FastifyInstance;
export const handle = async (
event: APIGatewayProxyEvent,
context: Context
): Promise<APIGatewayProxyResult> => {
if (!fastifyInstance) {
const res = await bootstrap();
fastifyInstance = res.fastifyInstance;
}
return await proxy(fastifyInstance, event, context);
};
144
(Optional)Serverless Framework でデプロイ
Dockerfile
FROM public.ecr.aws/lambda/nodejs:14
WORKDIR /app
COPY . .
RUN npm run build
※Lambda は環境変数のサイズ制限(4KB)があるためコンテナイメージ
でデプロイしています
145
serverless.yml
org: xxxxx
app: xxxxx
service: xxxxx
provider:
name: aws
profile: serverless
region: us-east-1
# runtime: nodejs14.x
ecr:
images:
appimage:
path: ./
environment:
NODE_ENV: production
146
api:
# url: true
memorySize: 512
timeout: 30
image:
name: appimage
command:
- /app/dist/handlers/api/handler.handle
entryPoint:
- "/lambda-entrypoint.sh"
events:
- http:
cors: true
path: "/"
method: any
- http:
cors: true
path: "{proxy+}"
method: any
147
package.json
"scripts": {
"sls:deploy:prod": "serverless deploy --stage production",
}
デプロイ
$ npm run sls:deploy:prod
148
オフロード処理の実装
149
オフロード処理の実装
公式ドキュメントのStandalone applicationsを多少参考に実装しまし
ょう
先程のサーバーレス起動に近いですが、単独起動を使って起動し、特
定の処理を適したサイズの Lambda で実行して API サーバーに負荷が
かからないようにしています
150
オフロード処理の実装ポイントは 3 + 1 つです
1. スタンドアロン起動関数を用意する
2. Lambda のハンドラーを用意する
3. (Optional)Serverless Framework でデプロイ
4. InvokeService を用意して Lambda を呼び出す
151
スタンドアロン起動関数を用意する
export async function bootstrapStandalone(): Promise<{
app: INestApplicationContext;
}> {
const app = await NestFactory.createApplicationContext(AppModule);
return { app };
}
152
Lambda のハンドラーを用意する handlers/sync-accounts/handler.ts
let app: INestApplicationContext;
export async function handle(event: {
props: SyncAccountsInput;
ctx: UserContext;
}): Promise<boolean> {
if (!app) {
const res = await bootstrapStandalone();
app = res.app;
}
const { props, ctx } = event;
try {
const usecase = app
.select(AccountModule)
.get(SyncAccountsUsecase, { strict: true });
return usecase.execute(props, UserContext.createBy(ctx));
} catch (e) {
console.dir(e);
throw e;
}
}
153
(Optional)Serverless Framework でデプロイ
functions:
syncAccounts:
memorySize: 2048
timeout: 900
image:
name: appimage
command:
- /app/dist/handlers/sync-accounts/handler.handle
entryPoint:
- "/lambda-entrypoint.sh"
154
InvokeService を用意して Lambda を呼び出す
import * as AWS from 'aws-sdk';
@Injectable()
export class InvokeService {
constructor(private readonly configService: ConfigService) {}
async syncAccounts(
props: SyncAccountsInput,
ctx: UserContext,
localHandler: (event: {
props: SyncAccountsInput;
ctx: UserContext;
}) => Promise<boolean>
): Promise<boolean> {
return this.invoke(this.configService.get('invoke.syncAccountsName'), props, ctx, localHandler);
}
155
private async invoke<P, R>(
functionName: string,
props: P,
ctx: any,
localHandler: (event: { props: P; ctx: any }) => R
): Promise<R> {
const type = this.configService.get('invoke.type');
if (type === 'lambda') {
const lambda = new AWS.Lambda({
region,
credentials: {
accessKeyId: accessKey,
secretAccessKey: secretAccessKey,
},
});
const payload = JSON.stringify({
props,
ctx,
});
156
const res = await lambda
.invoke({
FunctionName: functionName,
InvocationType: 'RequestResponse',
Payload: payload,
})
.promise();
const data = res.$response.data?.Payload
? JSON.parse(res.$response.data.Payload.toString())
: undefined;
if (data != null && typeof data === 'object' && 'errorType' in data) {
switch (data.errorType) {
case 'NotFoundException':
throw new NotFoundException(data.errorMessage);
case 'ForbiddenException':
throw new ForbiddenException(data.errorMessage);
default:
throw new InternalServerErrorException(data.errorMessage);
}
}
return data;
} else {
return await localHandler({ props, ctx });
}
}
}
157
@WorkerProcessor(QueueName.SyncAccounts)
export class SyncAccountProcessor {
constructor(
private readonly invokeService: InvokeService,
private readonly syncAccountUsecase: SyncAccountsUsecase
) {}
@Process()
async process(job: Job): Promise<boolean> {
const { props, ctx } = parseJobData<SyncAccountsInput>(job.data);
return await this.invokeService.syncAccounts(props, ctx, ({ props, ctx }) =>
this.syncAccountUsecase.execute(props, ctx)
);
}
}
158
テストの実装
159
テストの実装
公式ドキュメントのTestingを参考に実装しましょう
弊社では単体テストと、ユースケースから DB まで含めたバックエン
ドの範囲での E2E のテストを書いています
テストの実装ポイントは 3 つです
テスト用のアプリモジュールを用意する
テストを書いて実行
160
テスト用のアプリモジュールを用意する
function createMetadata(options?: AppModuleOptions): ModuleMetadata {
// AppModuleと共通で使うModuleMetadata
}
@Module({})
export class TestAppModule {
static forRoot(options?: AppModuleOptions): DynamicModule {
return {
module: TestAppModule,
...createMetadata(options),
};
}
}
※DB 接続先を変えるためオプションを渡せるようにしている 161
export async function createTestApp(name: string): Promise<TestApp> {
const dbName = generateTestDbName(name);
const [mongo, redis] = await Promise.all([
createMongodb(dbName),
createRedis(),
]);
const [mongoUrl, redisHost, redisPort] = await Promise.all([
mongo.getUri(),
redis.getHost(),
redis.getPort(),
]);
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
TestAppModule.forRoot({
mongoUrl,
redisHost,
redisPort,
}),
],
}).compile();
const app = moduleFixture.createNestApplication(new FastifyAdapter());
await app.init();
return {
app,
mongo,
redis,
};
}
162
export async function destroyTestApp(props: TestApp): Promise<void> {
const { app, mongo, redis } = props;
await app.close();
await Promise.all([mongo.stop(), mongo.cleanup(), redis.stop()]);
}
163
テストを書いて実行
describe("GetUsersUsecase", () => {
let _: {
testApp: TestApp;
adminService: AdminService;
org1: Organization;
a: Member;
};
164
beforeAll(async () => {
const testApp = await createTestApp("GetUsersUsecase");
const { app } = testApp;
const adminService = app.get(AdminService);
const [org1] = await Promise.all([
adminService.adminCreateOrganization({
workspace: "org1.com",
name: "org1",
}),
]);
const [a] = await Promise.all([
adminService.adminCreateMember({
name: "Aさん",
organizationId: org1.id,
}),
]);
_ = {
testApp,
adminService,
org1,
a,
};
});
165
beforeEach(async () => {
await _.adminService.userRepository.deleteMany({});
});
afterAll(async () => {
await destroyTestApp(_.testApp);
});
166
test("組織IDで絞り込む", async () => {
const usecase = _.testApp.app.get(GetUsersUsecase);
await Promise.all([
_.adminService.adminCreateUser({
name: "Bさん",
organizationId: _.org1.id,
}),
_.adminService.adminCreateUser({
name: "Cさん",
organizationId: _.org1.id,
}),
]);
const { data } = await usecase.execute(
{
organizationId: _.org1.id,
},
UserContext.createBy(_.a)
);
expect(data.length).toBe(3);
expect(data.map((v) => v.name)).toEqual(
expect.arrayContaining(["Aさん", "Bさん", "Cさん"])
);
});
});
167
API ドキュメンテーションの実装
168
API ドキュメンテーションの実装
公式ドキュメントのOpenApiを参考に実装しましょう
API ドキュメンテーションの実装ポイントは 3 つです
入力パラメータークラスに@ApiProperty デコレーターを付ける
コントローラーに ApiTags, ApiOperation デコレータを付ける
SwaggerModule を組み込む
169
入力パラメータークラスに@ApiProperty デコレーターを付ける
@ArgsType()
export class GetAccountsArgs extends PaginationArgs {
@ApiProperty({
required: true,
description: "組織ID",
})
@Field(() => ID)
organizationId: OrganizationId;
@ApiProperty({
required: false,
description: "契約ID",
})
@Field(() => ID, { nullable: true })
contractId?: ContractId;
}
170
コントローラーに ApiTags, ApiOperation デコレータを付ける
@ApiTags("アカウント管理")
@Controller("api/accounts")
export class AccountController extends BaseApiController {
constructor(private readonly getAccountsUsecase: GetAccountsUsecase) {
super();
}
@ApiOperation({ summary: "アカウント一覧" })
@Get()
async accounts(
@Query() props: GetAccountsArgs,
@CurrentUserContext() ctx: UserContext
): Promise<PaginatedAccount> {
return this.getAccountsUsecase.execute(props, ctx);
}
}
171
SwaggerModule を組み込む
export async function bootstrap() {
const fastifyInstance = fastify.fastify();
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(fastifyInstance)
);
app.useGlobalPipes(new ValidationPipe());
// Swagger
const config = new DocumentBuilder()
.setTitle("エンドポイント一覧")
.setDescription("提供しているAPIのエンドポイント一覧です")
.setVersion("1.0")
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("docs", app, document);
await app.listen(process.env.PORT ?? 4000, "0.0.0.0");
}
172
173
デバッグの方法
174
デバッグの方法
VSCode を使っている場合、容易にデバッガを利用することができま
す
デバッグの方法のポイントは 1 つです
JavaScript Debug Terminal(または自動アタッチ設定)で開発サーバ
ーを起動する
175
JavaScript Debug Terminal で起動する
176
(または)自動アタッチを有効にする
177
178
REPL の利用
179
REPL の利用
公式ドキュメントのREPLを参考に実装しましょう
NestJS v9 から追加されました。対話形式でユースケースやサービス
を呼び出して試すことができます。(もちろん先程のデバッグも効き
ます)
REPL の利用のポイントは 3 つです
REPL 起動用の関数とスクリプトを追加する
REPL を起動する
180
REPL 起動用の関数とスクリプトを追加する
repl.ts
import { repl } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap() {
await repl(AppModule);
}
bootstrap();
181
"scripts": {
"repl": "nest start --watch --entryFile repl",
}
182
REPL を起動する
$ npm run repl
183
184
185
186
以上です
公式ドキュメント万歳!
187
フロントエンドまわりキーワード
本当はフロントエンドについても色々書きたかったのですが、流石に
割愛します。項目だけ。
React + vite (vite最高)
GraphQL Code Generatorで 型安全 に GraphQL を呼び出す React 用
の カスタムフックを自動生成
Netlify のPrerenderingを使って 動的なOGPの生成
アプリ化するならCapacitor最高
chromaticで Storybook の 画像回帰テスト
testimで E2Eテスト
188
ありがとうございました
189

More Related Content

What's hot

AWSにおけるバッチ処理の ベストプラクティス - Developers.IO Meetup 05
AWSにおけるバッチ処理の ベストプラクティス - Developers.IO Meetup 05AWSにおけるバッチ処理の ベストプラクティス - Developers.IO Meetup 05
AWSにおけるバッチ処理の ベストプラクティス - Developers.IO Meetup 05
都元ダイスケ Miyamoto
 

What's hot (20)

今こそ知りたいSpring Batch(Spring Fest 2020講演資料)
今こそ知りたいSpring Batch(Spring Fest 2020講演資料)今こそ知りたいSpring Batch(Spring Fest 2020講演資料)
今こそ知りたいSpring Batch(Spring Fest 2020講演資料)
 
初心者向けMongoDBのキホン!
初心者向けMongoDBのキホン!初心者向けMongoDBのキホン!
初心者向けMongoDBのキホン!
 
Javaのログ出力: 道具と考え方
Javaのログ出力: 道具と考え方Javaのログ出力: 道具と考え方
Javaのログ出力: 道具と考え方
 
ツール比較しながら語る O/RマッパーとDBマイグレーションの実際のところ
ツール比較しながら語る O/RマッパーとDBマイグレーションの実際のところツール比較しながら語る O/RマッパーとDBマイグレーションの実際のところ
ツール比較しながら語る O/RマッパーとDBマイグレーションの実際のところ
 
SPAセキュリティ入門~PHP Conference Japan 2021
SPAセキュリティ入門~PHP Conference Japan 2021SPAセキュリティ入門~PHP Conference Japan 2021
SPAセキュリティ入門~PHP Conference Japan 2021
 
RLSを用いたマルチテナント実装 for Django
RLSを用いたマルチテナント実装 for DjangoRLSを用いたマルチテナント実装 for Django
RLSを用いたマルチテナント実装 for Django
 
例外設計における大罪
例外設計における大罪例外設計における大罪
例外設計における大罪
 
ドメイン駆動設計サンプルコードの徹底解説
ドメイン駆動設計サンプルコードの徹底解説ドメイン駆動設計サンプルコードの徹底解説
ドメイン駆動設計サンプルコードの徹底解説
 
PostgreSQLの行レベルセキュリティと SpringAOPでマルチテナントの ユーザー間情報漏洩を防止する (JJUG CCC 2021 Spring)
PostgreSQLの行レベルセキュリティと SpringAOPでマルチテナントの ユーザー間情報漏洩を防止する (JJUG CCC 2021 Spring)PostgreSQLの行レベルセキュリティと SpringAOPでマルチテナントの ユーザー間情報漏洩を防止する (JJUG CCC 2021 Spring)
PostgreSQLの行レベルセキュリティと SpringAOPでマルチテナントの ユーザー間情報漏洩を防止する (JJUG CCC 2021 Spring)
 
Dockerイメージの理解とコンテナのライフサイクル
Dockerイメージの理解とコンテナのライフサイクルDockerイメージの理解とコンテナのライフサイクル
Dockerイメージの理解とコンテナのライフサイクル
 
Python 3.9からの新定番zoneinfoを使いこなそう
Python 3.9からの新定番zoneinfoを使いこなそうPython 3.9からの新定番zoneinfoを使いこなそう
Python 3.9からの新定番zoneinfoを使いこなそう
 
PostgreSQLをKubernetes上で活用するためのOperator紹介!(Cloud Native Database Meetup #3 発表資料)
PostgreSQLをKubernetes上で活用するためのOperator紹介!(Cloud Native Database Meetup #3 発表資料)PostgreSQLをKubernetes上で活用するためのOperator紹介!(Cloud Native Database Meetup #3 発表資料)
PostgreSQLをKubernetes上で活用するためのOperator紹介!(Cloud Native Database Meetup #3 発表資料)
 
AWSにおけるバッチ処理の ベストプラクティス - Developers.IO Meetup 05
AWSにおけるバッチ処理の ベストプラクティス - Developers.IO Meetup 05AWSにおけるバッチ処理の ベストプラクティス - Developers.IO Meetup 05
AWSにおけるバッチ処理の ベストプラクティス - Developers.IO Meetup 05
 
テスト文字列に「うんこ」と入れるな
テスト文字列に「うんこ」と入れるなテスト文字列に「うんこ」と入れるな
テスト文字列に「うんこ」と入れるな
 
DockerとPodmanの比較
DockerとPodmanの比較DockerとPodmanの比較
DockerとPodmanの比較
 
Docker Compose 徹底解説
Docker Compose 徹底解説Docker Compose 徹底解説
Docker Compose 徹底解説
 
DDD x CQRS 更新系と参照系で異なるORMを併用して上手くいった話
DDD x CQRS   更新系と参照系で異なるORMを併用して上手くいった話DDD x CQRS   更新系と参照系で異なるORMを併用して上手くいった話
DDD x CQRS 更新系と参照系で異なるORMを併用して上手くいった話
 
DockerコンテナでGitを使う
DockerコンテナでGitを使うDockerコンテナでGitを使う
DockerコンテナでGitを使う
 
コンテナ未経験新人が学ぶコンテナ技術入門
コンテナ未経験新人が学ぶコンテナ技術入門コンテナ未経験新人が学ぶコンテナ技術入門
コンテナ未経験新人が学ぶコンテナ技術入門
 
イミュータブルデータモデルの極意
イミュータブルデータモデルの極意イミュータブルデータモデルの極意
イミュータブルデータモデルの極意
 

Similar to 実践 NestJS

TypeScript ファーストステップ (Rev.2) ~ Any browser. Any host. Any OS. Open Source. ~
TypeScript ファーストステップ (Rev.2) ~ Any browser. Any host. Any OS. Open Source. ~TypeScript ファーストステップ (Rev.2) ~ Any browser. Any host. Any OS. Open Source. ~
TypeScript ファーストステップ (Rev.2) ~ Any browser. Any host. Any OS. Open Source. ~
Akira Inoue
 
TypeScript ファーストステップ ~ Any browser. Any host. Any OS. Open Source. ~
TypeScript ファーストステップ ~ Any browser. Any host. Any OS. Open Source. ~TypeScript ファーストステップ ~ Any browser. Any host. Any OS. Open Source. ~
TypeScript ファーストステップ ~ Any browser. Any host. Any OS. Open Source. ~
Akira Inoue
 
Visual Studio 2012 Web 開発 ~ One ASP.NET から TypeScript まで ~
Visual Studio 2012 Web 開発 ~ One ASP.NET から TypeScript まで ~Visual Studio 2012 Web 開発 ~ One ASP.NET から TypeScript まで ~
Visual Studio 2012 Web 開発 ~ One ASP.NET から TypeScript まで ~
Akira Inoue
 
13016 n分で作るtype scriptでnodejs
13016 n分で作るtype scriptでnodejs13016 n分で作るtype scriptでnodejs
13016 n分で作るtype scriptでnodejs
Takayoshi Tanaka
 
TypeScript ファースト ステップ (v.0.9 対応版) ~ Any browser. Any host. Any OS. Open Sourc...
TypeScript ファースト ステップ (v.0.9 対応版) ~ Any browser. Any host. Any OS. Open Sourc...TypeScript ファースト ステップ (v.0.9 対応版) ~ Any browser. Any host. Any OS. Open Sourc...
TypeScript ファースト ステップ (v.0.9 対応版) ~ Any browser. Any host. Any OS. Open Sourc...
Akira Inoue
 
cocos2d-xにおけるBox2Dの利用方法および便利なツール
cocos2d-xにおけるBox2Dの利用方法および便利なツールcocos2d-xにおけるBox2Dの利用方法および便利なツール
cocos2d-xにおけるBox2Dの利用方法および便利なツール
Tomoaki Shimizu
 
エンタープライズ分野での実践AngularJS
エンタープライズ分野での実践AngularJSエンタープライズ分野での実践AngularJS
エンタープライズ分野での実践AngularJS
Ayumi Goto
 

Similar to 実践 NestJS (20)

条件式評価器の実装による管理ツールの抽象化
条件式評価器の実装による管理ツールの抽象化条件式評価器の実装による管理ツールの抽象化
条件式評価器の実装による管理ツールの抽象化
 
Node.jsでブラウザメッセンジャー
Node.jsでブラウザメッセンジャーNode.jsでブラウザメッセンジャー
Node.jsでブラウザメッセンジャー
 
TypeScript ファーストステップ (Rev.2) ~ Any browser. Any host. Any OS. Open Source. ~
TypeScript ファーストステップ (Rev.2) ~ Any browser. Any host. Any OS. Open Source. ~TypeScript ファーストステップ (Rev.2) ~ Any browser. Any host. Any OS. Open Source. ~
TypeScript ファーストステップ (Rev.2) ~ Any browser. Any host. Any OS. Open Source. ~
 
cocos2d-xとネイティブ間の連携
cocos2d-xとネイティブ間の連携cocos2d-xとネイティブ間の連携
cocos2d-xとネイティブ間の連携
 
Next2Dで始めるゲーム開発 - Game Development Starting with Next2D
Next2Dで始めるゲーム開発  - Game Development Starting with Next2DNext2Dで始めるゲーム開発  - Game Development Starting with Next2D
Next2Dで始めるゲーム開発 - Game Development Starting with Next2D
 
ボット開発でも DevOps! BotBuilder のテスト手法
ボット開発でも DevOps! BotBuilder のテスト手法ボット開発でも DevOps! BotBuilder のテスト手法
ボット開発でも DevOps! BotBuilder のテスト手法
 
Java/Androidセキュアコーディング
Java/AndroidセキュアコーディングJava/Androidセキュアコーディング
Java/Androidセキュアコーディング
 
TypeScript ファーストステップ ~ Any browser. Any host. Any OS. Open Source. ~
TypeScript ファーストステップ ~ Any browser. Any host. Any OS. Open Source. ~TypeScript ファーストステップ ~ Any browser. Any host. Any OS. Open Source. ~
TypeScript ファーストステップ ~ Any browser. Any host. Any OS. Open Source. ~
 
Metaprogramming Universe in C# - 実例に見るILからRoslynまでの活用例
Metaprogramming Universe in C# - 実例に見るILからRoslynまでの活用例Metaprogramming Universe in C# - 実例に見るILからRoslynまでの活用例
Metaprogramming Universe in C# - 実例に見るILからRoslynまでの活用例
 
CodeIgniterによるPhwittr
CodeIgniterによるPhwittrCodeIgniterによるPhwittr
CodeIgniterによるPhwittr
 
Visual Studio 2012 Web 開発 ~ One ASP.NET から TypeScript まで ~
Visual Studio 2012 Web 開発 ~ One ASP.NET から TypeScript まで ~Visual Studio 2012 Web 開発 ~ One ASP.NET から TypeScript まで ~
Visual Studio 2012 Web 開発 ~ One ASP.NET から TypeScript まで ~
 
VSCodeで始めるAzure Static Web Apps開発
VSCodeで始めるAzure Static Web Apps開発VSCodeで始めるAzure Static Web Apps開発
VSCodeで始めるAzure Static Web Apps開発
 
Das 2015
Das 2015Das 2015
Das 2015
 
13016 n分で作るtype scriptでnodejs
13016 n分で作るtype scriptでnodejs13016 n分で作るtype scriptでnodejs
13016 n分で作るtype scriptでnodejs
 
TypeScript ファースト ステップ (v.0.9 対応版) ~ Any browser. Any host. Any OS. Open Sourc...
TypeScript ファースト ステップ (v.0.9 対応版) ~ Any browser. Any host. Any OS. Open Sourc...TypeScript ファースト ステップ (v.0.9 対応版) ~ Any browser. Any host. Any OS. Open Sourc...
TypeScript ファースト ステップ (v.0.9 対応版) ~ Any browser. Any host. Any OS. Open Sourc...
 
Windows PowerShell 2.0 の基礎知識
Windows PowerShell 2.0 の基礎知識Windows PowerShell 2.0 の基礎知識
Windows PowerShell 2.0 の基礎知識
 
cocos2d-xにおけるBox2Dの利用方法および便利なツール
cocos2d-xにおけるBox2Dの利用方法および便利なツールcocos2d-xにおけるBox2Dの利用方法および便利なツール
cocos2d-xにおけるBox2Dの利用方法および便利なツール
 
基礎から見直す ASP.NET MVC の単体テスト自動化方法 ~ Windows Azure 関連もあるかも~
基礎から見直す ASP.NET MVC の単体テスト自動化方法 ~ Windows Azure 関連もあるかも~基礎から見直す ASP.NET MVC の単体テスト自動化方法 ~ Windows Azure 関連もあるかも~
基礎から見直す ASP.NET MVC の単体テスト自動化方法 ~ Windows Azure 関連もあるかも~
 
Android Studioの魅力
Android Studioの魅力Android Studioの魅力
Android Studioの魅力
 
エンタープライズ分野での実践AngularJS
エンタープライズ分野での実践AngularJSエンタープライズ分野での実践AngularJS
エンタープライズ分野での実践AngularJS
 

実践 NestJS