28
2017

TypeScriptで作るアノテーションだらけのWebアプリ

CATEGORYJavaScript
先日TypeScriptのORMとして TypeORM というものを発見。これのサンプルを見ていたところ、さらに TypeDI やら routing-controllers というライブラリ群を組み合わせることで、今までのJavaScript界とは打って変わったアノテーション(デコレーター)だらけのJavaっぽい、イケてるWebアプリを作れることが判明したので、その紹介をする。まずは概要。

TypeORM
TypeScript用のO/Rマッパー。動的言語で人気のActive Record…ではなく、アノテーションでエンティティを定義して、EntityManagerで操作するというJavaのJPAに近い(?)感じのORM。
TypeDI
TypeScriptでアノテーションを使ってサービスなどをDIするためのライブラリ。
routing-controllers
express.jskoa.js と組み合わせて、TypeScriptでアノテーションを使ったルーティング設定を実現するライブラリ。express.js の手続き的なコードを隠して、実際のロジックだけを実装するためのラッパーみたいな感じ。

はい、この後のサンプルを見ていくと実感できると思いますが、ほんとJavaのアノテーション文化をTypeScript上で再現したようなライブラリ群です。ちなみに、全て同一の作者さんが作られており、全部組み合わせる前提な気がします。

では、以下実際にこれらのライブラリを組み合わせたWeb APIのサンプルをどうぞ。なおサンプルの発展版をGitHubに上げているので、詳しく見たい方はそちらも参照ください。

Entity

まずはテーブル定義となるエンティティから。

entities/blog.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany } from "typeorm";
import { IsNotEmpty } from "class-validator";
import { Article } from "./article";

@Entity()
export class Blog {
@PrimaryGeneratedColumn()
id: number;

@Column({ unique: true })
@IsNotEmpty()
title: string;

@Column()
@IsNotEmpty()
author: string;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;

@OneToMany(type => Article, article => article.blog)
articles: Article[];
}

entities/article.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from "typeorm";
import { IsNotEmpty, IsInt } from "class-validator";
import { Blog } from "./blog";

@Entity()
export class Article {
@PrimaryGeneratedColumn()
id: number;

@ManyToOne(type => Blog, blog => blog.articles, {
nullable: false,
})
@JoinColumn({ name: 'blogId' })
blog: Blog;

@Column()
@IsNotEmpty()
title: string;

@Column("text")
@IsNotEmpty()
body: string;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
だいたい見たまんま。TypeORMのアノテーションでカラム定義をして、関連を定義して…っという普通に想像される感じです。詳しい定義の仕方は前述の公式サイト参照で。
なお、↑のサンプルにはバリデーション用のアノテーション(@IsNotEmpty() とか)もありますが、これは routing-controllers 用です。TypeORM単品だとバリデーション機能はないみたいなので注意。

Service

サービスっていうかロジックっていうか、要するにDIされるビジネスロジック層。今回のサンプルでは、エンティティを操作する人です。

services/blog-service.ts
import { Service } from "typedi";
import { Repository, FindOptions } from "typeorm";
import { OrmRepository } from "typeorm-typedi-extensions";
import { BadRequestError, NotFoundError } from "routing-controllers";
import { Blog } from "../entities/blog";
import { Article } from "../entities/article";

@Service()
export class BlogService {
@OrmRepository(Blog)
private blogRepository: Repository<Blog>;

@OrmRepository(Article)
private articleRepository: Repository<Article>;

async findAndCount(options: { offset?: number, limit?: number, whereConditions?: {} } = {}): Promise<[Blog[], number]> {
const op: FindOptions = {
alias: 'blog',
whereConditions: options.whereConditions,
offset: options.offset || 0,
limit: options.limit || Number.MAX_SAFE_INTEGER,
};
return this.blogRepository.findAndCount(op);
}

async findOneById(id): Promise<Blog> {
const blog = await this.blogRepository.findOneById(id);
if (!blog) {
throw new NotFoundError(`blog is not found`);
}
return blog;
}

async insert(blog: Blog): Promise<Blog> {
let count = await this.blogRepository.count({ title: blog.title });
if (count > 0) {
throw new BadRequestError(`blog title is already existed`);
}
return this.blogRepository.persist(blog);
}

async update(blog: Blog): Promise<Blog> {
const old = await this.blogRepository.findOneById(blog.id);
if (!old) {
throw new NotFoundError(`blog is not found`);
}
const op: FindOptions = {
alias: "blog",
where: "title = :title && id != :id",
parameters: { title: blog.title, id: blog.id },
};
let count = await this.blogRepository.count(op);
if (count > 0) {
throw new BadRequestError(`blog title is already existed`);
}
return this.blogRepository.persist(Object.assign(old, blog));
}

async delete(id: number): Promise<Blog> {
const blog = await this.blogRepository.findOneById(id);
if (!blog) {
throw new NotFoundError(`blog is not found`);
}
let count = await this.articleRepository.count({ blogId: id });
if (count > 0) {
throw new BadRequestError(`blog has articles`);
}
return this.blogRepository.remove(blog);
}
}
ちょっと長くなってしまってサーセン。ポイントとしては、自分がDIされるために @Service() を宣言してるのと、エンティティを操作するためのリポジトリというものを @OrmRepository() でDIしているところでしょうか?
TypeORMのエンティティは、エンティティ自体はただの箱で、こうやってリポジトリやらEntityManagerやらから操作します。
で、今回は極めて薄いWeb APIなので、エラーはそのまま routing-controllers の各種HTTP系のエラーでぶん投げています。

Controller

コントローラ層。今回はJSONを返すWeb API的な例なので極めてシンプルです。

controllers/blog-controller.ts
import { JsonController, Param, Body, Get, Post, Put, Delete, QueryParam } from "routing-controllers";
import { Inject } from "typedi";
import { Blog } from "../entities/blog"
import { BlogService } from "../services/blog-service"

@JsonController("/blogs")
export class BlogController {
@Inject()
blogService: BlogService;

@Get("/")
getAll( @QueryParam("offset") offset: number, @QueryParam("limit") limit: number): Promise<{ list: Blog[], count: number }> {
return this.blogService.findAndCount({ offset, limit })
.then(([list, count]) => { return { list, count }; });
}

@Get("/:id")
getOne( @Param("id") id: number): Promise<Blog> {
return this.blogService.findOneById(id);
}

@Post("/")
post( @Body({ required: true }) blog: Blog): Promise<Blog> {
return this.blogService.insert(blog);
}

@Put("/:id")
put( @Param("id") id: number, @Body({ required: true }) blog: Blog): Promise<Blog> {
blog.id = id;
return this.blogService.update(blog);
}

@Delete("/:id")
remove( @Param("id") id: number): Promise<Blog> {
return this.blogService.delete(id);
}
}
素の express.js だとリクエストやレスポンスを直接触って、結果を返すのも res.json()next() と低レイヤーを意識&コールバックな感じでしたが、routing-controllers だと戻り値やPromiseで結果を返せばそれが返る今風な感じになっていて凄く良い。
またルーティングやパラメータの指定がアノテーションでよかったり、引数を勝手にインスタンスに変換してくれたり、サービスをDIで入れてたりというのもあって、非常にシンプルになって良い感じ♪

なお、公式サイトによれば普通のWebページを返したりも勿論できるようでしたが、アノテーションとかエラー処理とかはちょっと複雑になりそうだった。
まあNode.jsで普通のWebページ作る奴なんてそうそう居な(ry

ブートローダー

で、ここまで作ってきたEntityやらControllerやらですが、当然それだけでは読み込んでくれません。
という事で、index.js でこんな感じに読み込ませてやります。
import "reflect-metadata";
import { createConnection, useContainer as useContainerForOrm } from "typeorm";
import { Container } from "typedi";
import { createExpressServer, useContainer as useContainerForRouting } from "routing-controllers";

useContainerForOrm(Container);
useContainerForRouting(Container);
createConnection({
driver: {
type: "mysql",
host: "localhost",
port: 3306,
username: "typeorm_usr",
password: "typeorm001",
database: "typeorm_sample",
},
logging: {
logger: (level, message) => console.log(message),
logQueries: true,
logFailedQueryError: true,
},
entities: [
__dirname + "/entities/{*.ts,*.js}"
],
autoSchemaSync: true,
}).then(() => {
const app = createExpressServer({
routePrefix: "/api",
controllers: [__dirname + "/controllers/*.js"],
});

app.listen(process.env.PORT || 3000);
}).catch(console.error);
とはいっても、それぞれファイルが存在するフォルダを指定してやるぐらいでOK。
あとは各フレームワークを組み合わせる useContainer() を指定したり、DBの設定をしたり、ルーティングプレフィックスを付けたり、SQLの実行ログを吐かせたり、そんな感じで。

これで、http://localhost/api/blogs/ とかでAPIが叩けます。


…うん、本当に昔見たJavaのWebアプリみたいというか、たぶんそれを意識して作ったんだろうなぁと。
まだバージョンが若くAPIがあまり安定してなさそうなのが欠点ですが、結構よさげなので、最近のTypeScriptブームに合わせて花開く日も来るかも!?
スポンサーサイト

Tag: JavaScript TypeScript TypeORM TypeDI routing-controllers

0 Comments

Leave a comment