背景:
色々と巷で良いと言われているフロントエンドフレームワークを色々仕事で使った事があるのですが(Angular>React>Vue)、テストやプロトタイピング的な案件では、去年から全てSapperで作っています。
Svelteはシンプルで記述量も少なく、コードが読み易く再利用がしやすいのでとても気に入っていますが、バックエンド搭載でSSRにも対応しているSapperの方が便利なので普段はこちらを使います。
ただ、Sapperのバックエンドは癖が強く再利用がしにくいので、バックエンドに関しては、スピード感のある開発ができて本番運用もし易いNest.jsを使いたい。(結局は、好みなんですが)。
色々書いたのですが、主な要望は以下です。
- 型に関しては共通で書きたい!(将来のために!)
- RESTやDBなどの通信系が最初のウチは雑に書ける。(あまり時間を割きたくない)
- 一度作ったものが他の箇所で使用し易い。
- Front+Back系プロジェクト特有のゴチャつきを防ぎたい
説明中、用語の使い方や表現のゆらぎが気になる方は、下の記事を読むと多分頭が少しスッキリします。
ーお前らがModelと呼ぶアレをなんと呼ぶべきか。近辺の用語(EntityとかVOとかDTOとか)について整理しつつ考える
「リンク早よっ!」て方は、こちらから:github: Nest.js, Sapper with Clean Archtecture: Working Example
Framework構成
基本的に、nestをexpressで設定すると、sapperのミドルウェア使えるので設定しただけです。
秘密はそれだけ。
async function bootstrap() {
const app = await NestFactory.create(ServerModule);
app.setGlobalPrefix(endpoints.prefix); // 干渉を避けるためGlobalPrefix="api"
app.use(
compression({ threshold: 0 }),
sirv('static', { dev }), // SSRの要?
sapper.middleware({ignore: /^\/api(.*)/}), // # /apiから始まるpathには適応しない。
);
await app.listen(PORT || 3000);
}
bootstrap();
フォルダ構成
プロトタイピングとは言っても、案外MVPまでプロジェクトが進行してしまう事が良くあるので、ある程度本番でも、使える様にしておきます。
基本的には、"クリーンアーキテクチャの文脈"にそいつつ、使いやすさを考えながら組んでいます。ただ、*1ゴリゴリの実装は返って書く量が増えてしまうので、今回はみんな馴染みのあるMVCとDDDの風味を残しておきます。
*1. 実装クリーンアーキテクチャ
/domain: Entity
早速です、DDDならDomain、クリーンアーキテクチャならEntityに当たります。
名前はまちまちですが、考え方的には、次のapplication(Use Case)層で扱うときに、下の図くらいドメインモデルが抽象化されていると理想です。
ref: 関心事に相応しい命名をする
どういう事か、
図で言う。予約品(aggregate)はデータベースやシステムを見ると、顧客情報と予約情報と商品情報の集合です。
ドメインのRepositoryでは予約品という、システムから見て抽象化された業務で使う単位で、保存、読み出しできる事が理想です。
なので、分け方としては、以下のようにします。
domain/repository/yoyaku-hin.repository.ts
domain/model/yoyaku-hin.model.ts
domain/entity/kokyaku.entity.ts
domain/entity/yoyaku.entity.ts
domain/entity/shouhin.entity.ts
このようにrepositoryがaggregateをしているため、ここでCRUDを書いておくと、最初はControllerに直結して使う事ができます。
ここで、以下の様に雑でも、汎用なスーパークラスを作っておくと、ミスが減る上に、かなり時間が節約。
DDDでは、yoyaku-hin.modelも「一意な識別子によって特定される」ため、本当はEntityなのですが、ややこしいので
O/Rマッピングの対象を.entity.tsに書き、ドメインモデルを.model.tsに書いています。
// #shared/pattern/repository-controller.class.ts
export class RepositoryController<T> implements IRepositoryProxy {
constructor(
private readonly repository: IReposiotry
) { }
@Get()
findAll(@Query() filter) {
return this.repository.findAll(filter);
}
@Get(':id')
findById(@Param('id') id, @Query() filter) {
return this.repository.findAll(id, filter);
}
@Post()
create(@Body() body, @AuthUser() user) {
return this.repository.create(user, body);
}
}
// #interface/controller/yoyaku.controller.ts
import { endpoints } from 'interface/api/api.conf';
@Controller(endpoints.paths.yoyaku)
@UseGuards(AuthGuard())
export class YoyakuController extends RepositoryController<YoyakuHin>{
constructor(
yoyakuRepo: YoyakuRepository
) {
super(yoyakuRepo);
}
}
若干バカバカしく見えるかも知れないけど、コイツが結構便利で、プロジェクトが進行をしていっても、
成長するに連れてブラッシュアップしていったり、いくつかバージョンを作ると最後まで生き残ったりします。
ただ基本的には、Mock向けで複数人いる場合とりあえず繋げて、フロントエンドを開発し易くする事ができます。
/application: Use Case
RepositoryとAuthGuardとSuperControlerのコンボは強力で、一見「もう、やる事ねーじゃん。Application層、イラネーじゃん」となりそうですが。ここからが、本番です。
例えば、単純に予約品を登録(POST)するにしても、実際にはデータの保存だけでなく、認証や権限を確認したり、お客さんにメールを送ったりと沢山する事があります。
ここでもやっぱり、IRepositoryProxyを実装したSuperServiceを基本的には継承し、必要な箇所を上書きしていくと、時間が短縮できます。
@Injectable()
export class YoyakuService extends RepositoryService<YoyakuHin> {
constructor(
private readonly yoyakuRepo: YoyakuRepository,
private readonly smsService: SmsService,
) {
super(yoyakuRepo);
}
// 例:オリジナルを上書きし、デフォルト値を設定する
findAll(_filter): Promise<YoyakuHin[]> {
const filter = Object.assign({default: false}, _filter);
return this.yoyakuRepo.findAll(filter);
}
// 例:オリジナルを上書きし、保存後にSMSを送信する。
async create(body, user): Promise<YoyakuHin> {
const yoyakuHin = await this.yoyakuRepo.creat(body, user);
this.smsService.sendSendSMS(yoyakuHin);
return yoyakuHin;
}
}
/interface: Controller, Adapter
ここでは、システム間のインターフェースを設定します。
サーバのControllerとクライアントのService(Angular的?)に当たりますが、名前がService被りしている上に、application/serviceにきちんとロジック書いているなら、コイツの役目は基本にサーバーに繋ぐだけ。それ以外に変なことはして欲しくないので、Adapterに名前変更。
AuthentecatedAdapterとPublicAdapterぽいスーパークラス作り、各pathを変数に入れることで、ほぼコピペで済み、ミスが格段に減ります。
あとは、ここにDev/Hom/Prodのホスト名や各pathを書いておくと超便利。
// #interface/api/api.conf.ts
export const endpoints = {
prefix: `api`,
paths: {
yoyaku: `yoyaku-hin`
}
}
// #interface/adapter/yoyaku.adapter.ts
import { endpoints } from 'interface/api/api.conf';
export class YoyakuHinAdapter extends AuthentecatedAdapter<YoyakuHin> {
constructor(){
super(endpoints.paths.yoyaku);
}
}
// #interface/controller/yoyaku.controller.ts
import { endpoints } from 'interface/api/api.conf';
@Controller(endpoints.paths.yoyaku)
@UseGuards(AuthGuard())
export class YoyakuController extends RepositoryController<YoyakuHin>{
constructor(yoyakuRepo: YoyakuRepository) {
super(yoyakuRepo);
}
}
/infrastructure: 設定・インフラ
このにはサーバーやデータベースの接続設定、データベースのコネクターは/domain/*.entity.tsを読み込んで管理する。
以下の様にすると、オニオンアーキテクチャの様にデータベースが、Entityに依存する仕様に作れる。
// #/infrastructure/sql.conn.ts
import { YoyakuEntiy, KokyakuEntity, ShouHinEntity } from 'domain/model';
import { connect } from 'some-db-connector'
export const connection = connect({
models: [YoyakuEntiy, KokyakuEntity, ShouHinEntity]
});
/components: UI
Svelteのコンポーネントを記述し行きます。何のことはないですね、
componentsかuiか悩みましたが、馴染みがある方にしました。
/routes: Web
Sapperの構造上、コイツがウェブのルートに対応しています。これはもう好きに呼んで下さい。
試したい点
仕組みを調べる時間がなかったので、SSRの機構をそのままにしてあります。これをNestのモノに置き換えれるのか、また、パーフォーマンスが上がるのかが気になります。
やり残した事
プロトタイピングが主な使い方なので、ユニットテストの設定を移植してません(雑)。
storybookとか入れたらいい感じのボイラープレートになるかも知れんです。
後READMEとか、ちゃんと書きたいところです。
後書き
オニオンアーキテクチャで組んだのは、初めてなので、間違っていたり。改善できる点があれば、教えてくれるとありがたいです。
プロジェクト: github: Nest.js, Sapper with Clean Archtecture: Working Example