バックエンドアプリケーションがやってることは5つに要約できる
バックエンドアプリケーションがやってること
以前こんな記事を見かけました。
そこではアプリケーションのやっている内容を簡潔にまとめており、とても理解が深まった覚えがあります。これを僕の視点と経験からさらに解説してみようと思います。
バックエンドアプリケーションのやっている内容は
- 権限チェック
- インプット(request body, query, params)のバリデーション
- ドメインルールチェック
- CRUDアクション
- レスポンスをフォーマットし返却
の5つにまとめられます。
class コントローラー
{
更新系メソッド(リクエスト){
// 1. 権限チェック
if(リクエスト.ユーザー.権限 !== 'アドミン') throw '権限エラー!';
// 2. インプット(request body, query, params)のバリデーション
if(!リクエスト.ボディ.ユーザーID) throw 'バリデーションエラー! ユーザーIDが指定されていません。';
// 3. ドメインルールチェック
const モデル = モデルDB取得();
if(!モデル.更新可能?) throw 'バッドリクエストエラー! モデルが更新できません!';
// 4. CRUDアクション
モデル.更新(リクエスト.ボディ)
const 更新後のモデル = モデルDB更新(モデル);
// 5. レスポンスをフォーマットし返却
return 更新後のモデル.ユーザーID;
}
}
それぞれ詳しく解説していきます。
権限チェック
権限チェックとはそのAPIを実行するユーザー(場合によってはボット)がそのAPIを実行できるかチェックするものです。後述するドメインルールチェックとは異なり、ユーザーがチェック対象となります。
このチェックに失敗すればAPIを利用できないようにしたいため、API実行フローの1番最初に行います。ただし、利用しているフレームワークによっては、インプットバリデーションが1番最初になる可能性があります。その場合は2番めに行いましょう。インプットバリデーションが権限チェックより優先されると困ることに関しては、この記事の下の方にコラムとして書いてあるので参考にしてください。
権限チェックでどこを見るのか
権限の設計自体は3つのパターンが存在します。今回は一番オーソドックスな「機能と権限識別記号を紐付ける」パターンで解説したいと思います。
ユーザーモデル設計時に権限(permissionもしくはrole)という属性を付与します。
id | name | permission |
---|---|---|
1 | 山田 | ADMIN |
2 | 田中 | USER |
3 | 佐藤 | SUPER_USER |
そしてこの権限のユーザーがAPIを利用できるか調べて、無理であれば403エラーを返します。
アドミン以外利用できないAPIの例。
class コントローラー
{
更新系メソッド(リクエスト){
// 1. 権限チェック
if(リクエスト.ユーザー.permission !== 'ADMIN') throw '権限エラー!';
// ...省略
}
}
権限チェック対象の属性は必ずしもユーザーモデルの属性とは限らない
テーブルの設計やモデルに設計によっては別に役割テーブルを持っている可能性があります。例えばチャットアプリがあり、ユーザーモデルの権限とは別に、チャットルームごとにロールを持っているとします。この場合、ロールテーブルを取得して権限チェックする必要があります。
ユーザーテーブル
id | name |
---|---|
1 | 山田 |
2 | 田中 |
3 | 佐藤 |
ルームテーブル
id | name |
---|---|
1 | 山田さん恋のお悩み教室 |
チャットロールテーブル
id | userId | role |
---|---|---|
1 | 1 | CLIENT |
2 | 2 | SUPPORTER |
3 | 3 | FACILITATOR |
class コントローラー
{
更新系メソッド(リクエスト){
// 1. 権限チェック
const role = ロールモデル取得(リクエスト.ユーザー.id);
if(role !== 'FACILITATOR') throw '権限エラー!';
// ...省略
}
}
ロールはチャットというドメインの関心ごとだという観点から、これをドメインルールチェックの段階で行う人もいます。僕はドメインルールチェックはリクエスト対象に対して行い、権限チェックはリクエストユーザーに対して行うものだという考えています。だから、ドメインルールチェックではなく権限チェックで行うことにしています。ドメインルールチェックで行うにしても、そのなかでうまく分離してあげるとコードの再利用性が高まります。これについてはドメインルールチェックの項目で扱いたいと思います。
コラム インプットバリデーションが権限チェックより優先されると困ること
もしインプットバリデーションが権限チェックより優先されたとします。すると、そのAPIを叩く権限がないユーザーに、そのAPIが扱うリクエストボディの内容などが400エラーの内容からバレてしまう可能性があります。別にそれくらい構わないよっていうときは問題にならないですね。
あとは、APIテスト時に一般的にバリデーションエラーより先に、権限エラーを想定してチェックすると思います。その時403ステータスコードを期待しるのに、400ステータスコードが帰ってきたら困るので、いちいちリクエストをちゃんとしたものにする必要があります。
インプット(request body, query, params)のバリデーション
システムには境目、IO、内と外の境界というものが存在します。そして外の世界は危険な無法地帯なのでそのまま内の世界に入れてしまうと悪意を持って荒らされたり、もしくは悪意がなくても無自覚に想定するデータの形と違うことから後続するアクションが行えない可能性があります。これらを関門で弾いてやるのがインプットのバリデーションです。セキュリティ的には権限チェックで行いますが、単純なフォーマットチェックはインプットのバリデーション段階で行います。
Webバックエンドアプリケーションでのインプットは主に4種類あります。
- リクエストボディ
- リクエストクエリ
- リクエストパラム
- リクエストヘッダ
これらのフォーマットチェックが失敗した際に400エラーを返します。
ドメインルールチェック
権限チェックはリクエスト送信元(ユーザー)のチェックでした。それに対してドメインルールチェックは、リクエスト対象(コトやモノやヒト)のチェックです。
権限チェックとドメインルールチェックの違い
例えばアカウント編集APIがあるとします。次のようなルールがあります。
- アカウントは本人しか編集できない。
- 例外的にアドミン権限のユーザーは他の人のアカウントを編集できる。
前者はドメインルールチェックで、後者は権限チェックです。1つのAPIで両方のユースケースに対応すると次のようなコードになってしまいます。
class アカウントコントローラー
{
名前更新メソッド(リクエスト){
// 1. 権限チェック
// アドミンも一般ユーザーの利用可能なため権限チェックなし。
// 2. インプットのバリデーション
// 省略
// 3. ドメインルールチェック
// アカウントは本人しか編集できない。
// 例外的にアドミン権限のユーザーは他の人のアカウントを編集できる。
const アカウントモデル = モデルDB取得();
if(リクエスト.ユーザー.権限 !== 'ADMIN'){
if(!アカウントモデル.編集可能by(リクエスト.ユーザー.id)) throw 'バッドリクエストエラー!';
}
// 4. CRUDアクション
アカウントモデル.名前更新(リクエスト.ボディ);
const 更新後のモデル = モデルDB更新(アカウントモデル);
// 5. レスポンスをフォーマットし返却
return 更新後のモデル.ユーザーID;
}
}
class アカウントモデル
{
名前更新(名前){
this.名前 = 名前
}
}
こうすると、権限チェックとドメインルールチェックが入まじいってしまいます。このような場合はAPIを分けるとスッキリします。
class アカウントコントローラー
{
名前更新メソッド(リクエスト){
// 1. 権限チェック
// アドミンも一般ユーザーの利用可能なため権限チェックなし。
// 2. インプットのバリデーション
// 省略
// 3. ドメインルールチェック
// アカウントは本人しか編集できない。
// 例外的にアドミン権限のユーザーは他の人のアカウントを編集できる。
const アカウントモデル = モデルDB取得();
if(!アカウントモデル.編集可能by(リクエスト.ユーザー.id)) throw 'バッドリクエストエラー!';
// 4. CRUDアクション
アカウントモデル.名前更新(リクエスト.ボディ);
const 更新後のモデル = モデルDB更新(アカウントモデル);
// 5. レスポンスをフォーマットし返却
return 更新後のモデル.ユーザーID;
}
アドミン名前更新メソッド(リクエスト){
// 1. 権限チェック
if(リクエスト.ユーザー.permission !== 'ADMIN') throw '権限エラー!';
// 2. インプットのバリデーション
// 省略
// 3. ドメインルールチェック
// アドミンはドメインルールチェックはスキップ。
// 4. CRUDアクション
const アカウントモデル = モデルDB取得();
アカウントモデル.名前更新(リクエスト.ボディ);
const 更新後のモデル = モデルDB更新(アカウントモデル);
// 5. レスポンスをフォーマットし返却
return 更新後のモデル.ユーザーID;
}
}
CRUDアクション
いよいよAPIのメインです。ここではGET系APIであればモデル取得するだけですが、POSTやPUT系APIであれば、モデルとの相互作用があります。
RepositoryとEntityパターンを採用していれば
- fetch
- modify
- update
のコードになり、よくトランザクションが導入されます。
ActiveRecordパターンを採用していればRepositoryとEntityパターンと同じか、もしくは
- fetch
- update
のコードになります。こちらもトランザクションがよく導入されます。
ちなみにそもそもSQLのUPDATE句自体はアトミックに設計されているのですが、fetch,modify,updateパターンはすべてのカラムを取得してすべてのカラムを更新するのでアトミックではなくなります。その結果トランザクションがほぼ必須となります(同タイミングの更新により整合性が壊れる可能性があるから)。
class アカウントコントローラー
{
名前更新メソッド(リクエスト){
// 1. 権限チェック
// アドミンも一般ユーザーの利用可能なため権限チェックなし。
// 2. インプットのバリデーション
// 省略
// 3. ドメインルールチェック
// 省略
// 4. CRUDアクション
const アカウントモデル = モデルDB取得();
アカウントモデル.名前更新(リクエスト.ボディ);
const 更新後のモデル = モデルDB更新(アカウントモデル);
// 5. レスポンスをフォーマットし返却
return 更新後のモデル.ユーザーID;
}
}
class アカウントモデル
{
名前更新(名前){
this.名前 = 名前
}
}
モデル内部に権限チェックを入れない
CRUDアクション自体に権限の知識入る可能性があります。例えばアカウントの更新APIがあったとして、
- 一般ユーザーは名前を更新できる。
- アドミンユーザーはステータスを更新できる。
とします。
これらを1つにまとめると次のようなコードになったとします。
class アカウントコントローラー
{
更新メソッド(リクエスト){
// 1. 権限チェック
// アドミンも一般ユーザーの利用可能なため権限チェックなし。
// 2. インプットのバリデーション
// 省略
// 3. ドメインルールチェック
// 省略
// 4. CRUDアクション
const アカウントモデル = モデルDB取得();
アカウントモデル.更新(リクエスト.ボディ, リクエスト.ユーザー);
const 更新後のモデル = モデルDB更新(アカウントモデル);
// 5. レスポンスをフォーマットし返却
return 更新後のモデル.ユーザーID;
}
}
class アカウントモデル
{
更新(更新ボディ, リクエストユーザー){
this.名前 = 更新ボディ.名前;
if(リクエストユーザー.permission == 'ADMIN'){
this.status = 更新ボディ.status;
}
}
}
このコードは何が悪いでしょうか?まとめると次の点です。
- アカウントモデル自体がリクエストユーザーモデルを知ってしまっている。密結合している。あかう
- 更新メソッド内部に分岐が生じている。モデルが若干複雑。
そのためアカウントモデルにリクエストユーザーそのものを渡さず、フラグを渡すことにしました。
class アカウントコントローラー
{
更新メソッド(リクエスト){
// 1. 権限チェック
// アドミンも一般ユーザーの利用可能なため権限チェックなし。
// 2. インプットのバリデーション
// 省略
// 3. ドメインルールチェック
// 省略
// 4. CRUDアクション
const アカウントモデル = モデルDB取得();
アカウントモデル.更新(リクエスト.ボディ, リクエスト.ユーザー.permission == 'ADMIN');
const 更新後のモデル = モデルDB更新(アカウントモデル);
// 5. レスポンスをフォーマットし返却
return 更新後のモデル.ユーザーID;
}
}
class アカウントモデル
{
更新(更新ボディ, isAdmin){
this.名前 = 更新ボディ.名前;
if(isAdmin){
this.status = 更新ボディ.status;
}
}
}
これでアカウントモデルとリクエストユーザーモデルの密結合は解決しましたが、まだフラグの分モデルに複雑度があります。このような場合は、そもそもメソッドとAPIを分けることも検討しましょう。
class アカウントコントローラー
{
更新メソッド(リクエスト){
// 1. 権限チェック
// アドミンも一般ユーザーの利用可能なため権限チェックなし。
// 2. インプットのバリデーション
// 省略
// 3. ドメインルールチェック
// アカウントは本人しか編集できない。
// 例外的にアドミン権限のユーザーは他の人のアカウントを編集できる。
const アカウントモデル = モデルDB取得();
if(!アカウントモデル.編集可能by(リクエスト.ユーザー.id)) throw 'バッドリクエストエラー!';
// 4. CRUDアクション
アカウントモデル.更新(リクエスト.ボディ);
const 更新後のモデル = モデルDB更新(アカウントモデル);
// 5. レスポンスをフォーマットし返却
return 更新後のモデル.ユーザーID;
}
アドミン更新メソッド(リクエスト){
// 1. 権限チェック
if(リクエスト.ユーザー.permission !== 'ADMIN') throw '権限エラー!';
// 2. インプットのバリデーション
// 省略
// 3. ドメインルールチェック
// アドミンはドメインルールチェックはスキップ。
// 4. CRUDアクション
const アカウントモデル = モデルDB取得();
アカウントモデル.アドミン更新(リクエスト.ボディ);
const 更新後のモデル = モデルDB更新(アカウントモデル);
// 5. レスポンスをフォーマットし返却
return 更新後のモデル.ユーザーID;
}
}
class アカウントモデル
{
更新(更新ボディ){
this.名前 = 更新ボディ.名前;
}
アドミン更新(更新ボディ){
this.名前 = 更新ボディ.名前;
this.status = 更新ボディ.status;
}
}
こうすると次のメリットが生まれます。
- アドミン用のAPIと一般ユーザー用のAPIが別なので、違う育て方ができる。
- アドミン用のAPIと一般ユーザー用のAPIで別々のインプットバリデーションをかけることができる。
例えばAWSの cognito SDK もこのような設計になっています。
一般的に、内部の複雑さ(ifの多さ)は、インターフェースを増やすことで減らすことができるときがあります。そうするとモデルがスリムになり、メソッドが一つのことだけを担当するようになります。僕はこれを条件のリフトアップと呼んでいます。
レスポンスをフォーマットし返却
いよいよ最後です。単純にモデルを返却することもありますが、セキュリティの観点からも、基本的に必要なものだけ公開しましょう!
モデルの属性を列挙する方法もありますが、僕のおすすめはレスポンス専用のモデルを作成し、最後にかぶせることです。
モデルの属性を列挙する方法
class アカウントコントローラー
{
取得メソッド(リクエスト){
// 1. 権限チェック
// 省略
// 2. インプットのバリデーション
// 省略
// 3. ドメインルールチェック
// 省略
// 4. CRUDアクション
const モデル = モデルDB取得(リクエスト.パラム);
// 5. レスポンスをフォーマットし返却
return {
id: モデル.id,
name: モデル.name
}
}
}
レスポンス専用のモデルを用意しかぶせる方法
class アカウントコントローラー
{
取得メソッド(リクエスト){
// 1. 権限チェック
// 省略
// 2. インプットのバリデーション
// 省略
// 3. ドメインルールチェック
// 省略
// 4. CRUDアクション
const モデル = モデルDB取得(リクエスト.パラム);
// 5. レスポンスをフォーマットし返却
return new アカウントDTO(モデル)
}
}
class アカウントDTO
{
constructor(args){
this.name = args.name;
this.id = args.id
}
toJSON(){
return {
name: this.name,
id: this.id,
}
}
}
業務では、このレスポンス用のモデルをopenapiのmodelからopenapi-typescriptとts-morphを使ってコマンドで生成できるようにしています。openapi専用のレポジトリを作って、pushされたら、github actionsでopenapiから型tsファイルを生成し、さらにそこからts-morphを使って、DTOを生成しています。
このスクリプトをnpmで公開できればいいのですが、めっちゃ汚いかつテストしていないので見せられないよ状態になっています。。。
一応参考までに以下のような感じです。
name: auto release
on:
push:
# mainブランチにコミットがpushされたときに限定
branches:
- master
# 上記条件に加えてopenapi.latest.yamlとbuildDto.jsが変更されたときのみという条件を追加
paths:
- 上記条件に加えてopenapi.latest.yaml
- buildDto.js
- .github/workflows/release.yml
jobs:
auto-release:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_IT_VERSION: 14.2.1
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # github packagesのときはNPM_TOKENではなくGITHUB_TOKEN
steps:
- name: Check out codes
uses: actions/checkout@v2
with:
fetch-depth: 0
- uses: actions/setup-node@v2
with:
node-version: '14.x'
registry-url: 'https://npm.pkg.github.com'
# Defaults to the user or organization that owns the workflow file
scope: '@団体orユーザー'
- name: generate scheme from openapi document
run: npx openapi-typescript openapi.latest.yaml --output src/scheme.ts
- name: generate dto from scheme
run: npm ci && rm ./src/dto/* && node buildDto.js && npx tsc
- name: Set releaser settings
run: |
git config --global user.name release-machine
git config --global user.email email@example.com
- name: Major release
id: major
if: contains(toJSON(github.event.commits.*.message), 'bump up version major')
run: npx release-it@${RELEASE_IT_VERSION} -- major --ci
- name: Minor release
id: minor
# メジャーバージョンアップをしていないときマイナーバージョンアップを行なうか
if: steps.major.conclusion == 'skipped' && contains(toJSON(github.event.commits.*.message), 'bump up version minor')
run: npx release-it@${RELEASE_IT_VERSION} -- minor --ci
- name: Patch release
# コミットメッセージに特に指定がない場合はマイナーバージョンを更新する
if: "!(steps.major.conclusion == 'success' || steps.minor.conclusion == 'success')"
run: npx release-it@${RELEASE_IT_VERSION} -- patch --ci
const {
Project,
SyntaxKind
} = require("ts-morph");
const fs = require('fs')
// const Keys = (intName: string): void => {
const project = new Project();
const sourceFile = project.addSourceFileAtPath(`src/scheme.ts`);
const node = sourceFile.getInterface('components');
const properties = node.getProperty(p => p.getName() === 'schemas')?.getChildrenOfKind(SyntaxKind.TypeLiteral)[0].getProperties();
properties?.forEach(property => {
const childProperties = [];
if(property){
if(property.getChildrenOfKind(SyntaxKind.TypeLiteral)[0]){
childProperties.push(...property.getChildrenOfKind(SyntaxKind.TypeLiteral)[0].getProperties());
}
if(property.getChildrenOfKind(SyntaxKind.IntersectionType)[0]){
if(property.getChildrenOfKind(SyntaxKind.IntersectionType)[0].getChildrenOfKind(SyntaxKind.TypeLiteral)[0]){
property.getChildrenOfKind(SyntaxKind.IntersectionType)[0].getChildrenOfKind(SyntaxKind.TypeLiteral).forEach(typeLiteral => {
childProperties.push(...typeLiteral.getProperties())
});
}
if(property.getChildrenOfKind(SyntaxKind.IntersectionType)[0].getChildrenOfKind(SyntaxKind.IndexedAccessType)[0]){
property.getChildrenOfKind(SyntaxKind.IntersectionType)[0].getChildrenOfKind(SyntaxKind.IndexedAccessType).forEach(indexed => {
const propertyName = indexed.getChildAtIndex(2).getText().replace(/"/g, '');
if(propertyName){
const ref = node.getProperty(p => p.getName() === 'schemas')?.getChildrenOfKind(SyntaxKind.TypeLiteral)[0].getProperty(p => p.getName() === propertyName);
// childProperties.push(...property.getChildrenOfKind(SyntaxKind.IntersectionType)[0].getChildrenOfKind(SyntaxKind.TypeLiteral)[0].getProperties());
if(ref.getChildrenOfKind(SyntaxKind.TypeLiteral)[0]){
childProperties.push(...ref.getChildrenOfKind(SyntaxKind.TypeLiteral)[0].getProperties());
}
}
})
}
}
}
const template = `import {components} from '../scheme';
export class ${property ? property.getName() : ''} {
${childProperties.map(p => {
return ` ${p.getText()}\n\n`
}).join('')} constructor(args: {
${childProperties.map(p => {
return ` ${p.getText()}\n`
}).join('')} }){
${childProperties.map(p => {
return ` this.${p.getName()} = args.${p.getName()};\n`
}).join('')} }
}
`
// 同期で行う場合
try {
fs.writeFileSync(`src/dto/${property.getName()}.ts`, template);
console.log(`wrote ${property.getName()} file!`);
}catch(error){
throw error
}
fs.appendFileSync("src/dto/index.ts", `export * from './${property.getName()}';\n`, (err) => {
if (error) throw error;
});
})
openapiのモデルの記法すべてに対応しているわけではないので、利用する際は注意してください。
もっといい方法があれば教えてください。
Discussion
バックエンドの主な目的のひとつに、ドメインロジック(ビジネスロジック)の実現(実行)があります。
そしてそれは、別の手段で実行されうるという性質を持っています。例えば、ユニットテストやcronのタスク、RPCなど。
それをふまえると、コントローラで実行すべき項目が変わってくるかもしれません。
コメントありがとうございます。
こちらに関してうまく理解ができなかったのですが、
コントローラーはいわゆるオニオンアーキテクチャにおけるコアのドメインロジックを利用する周辺部という立ち位置。今回のコントローラーはREST APIの形に特化しているが、ドメインロジック自体は、cronタスクから呼ばれたり、外部サービスやRPCなどを通して呼ばれることもあるので、ドメインロジックを呼んでいる以外の部分(今回ではCRUDアクション以外の部分)では、書き方が変わるという認識であってますか?
例えば、ユニットテストでアカウント情報を更新する処理をテストするケースを考えたとき、
などが考えられますが、本記事のコードだとコントローラーを利用するしかありません。
一方、このコントローラーはWebアプリケーション専用なので、ユニットテストを実装するには、リクエストオブジェクトを生成したり等する必要が出てきます。
「アカウント情報を更新する」というのがドメインロジックだとすると、それをコントローラーなしに実行できるようにしておくと、ユニットテストやその他からの呼び出しにも対応できます。
返信ありがとうございます!具体例わかりやすかったです。
こちら同意ですね。最初記事にするときに、コントローラーだけで完結するか、サービスレイヤなどを設けて ポートアダプターパターンのようにするか迷ったのですが、わかりやすさからコントローラー上ですべて完結させました。
もしサービスレイヤを設けるならこんなイメージですね。
アカウント情報を更新する処理をアカウントドメインとしたとき僕は以下のことを考えています。
以上から、もし「アカウント情報を更新する」というドメインロジックをユニットテストするなら
です。
コントローラをユニットテストするなら
です。
権限ドメインのユニットテストをするなら
イメージです。
5つの考えはrpcなどでも利用できると思ってます。