この記事は トラストバンク Advent Calendar 2022 の3日目の記事です。
はじめまして、 ふるさとチョイスのAPI開発を担当している山本です。トラストバンク歴10ヶ月、業務ではphp
でLaravel
使ってます。
トラストバンクでDeno使うのはどうですか?とそれとなくDeno(デノ)を推す意味も込めてDeno(とoak) でクリーンアーキテクチャーでAPI/WEBを作るまでの記事を書きます。本記事ではoak
とクリーンアーキテクチャーに関わらない箇所の説明は簡易なものになります。
Let's ハンズオン
事前準備
Denoに触れるのが一年ぶりなのでサラッと環境構築から始めます。細かい説明は省きます。
環境構築
1.WSLを起動
PS C:\Users\yamamoto> wsl -d Ubuntu-20.04-deno
2.deno最新版インストール
$ asdf plugin-list-all |grep deno
$ asdf plugin-add deno
$ asdf list-all deno
$ asdf install deno 1.28.1
$ mkdir -p ~/src/deno1_28_1-oak
$ cd ~/src/deno1_28_1-oak/
$ asdf local deno 1.28.1
$ asdf plugin-add sqlite
$ asdf install sqlite 3.40.0
$ asdf global sqlite 3.40.0
3.authbindのインストール
一般ユーザーでport80でListenするプロセスを立ち上げる為です
$ sudo apt install authbind
$ sudo toucn /etc/authbind/byport/80
$ sudo chown yamamoto /etc/authbind/byport/80
$ sudo chmod 755 /etc/authbind/byport/80
動作確認
1.httpサーバーが立ち上がる事の確認
deno http
で検索すると公式マニュアルがヒットするのでサンプルコードのポートだけ80に書き換えて使ってみましょう。
import { serve } from "https://deno.land/std@0.165.0/http/server.ts";
function handler(req: Request): Response {
return new Response("Hello, World!");
}
serve(handler, { port: 80});
起動する
$ authbind --deep deno run --allow-net main.ts
http://localhost/にアクセスしてHello, World!
が表示される事を確認します。
2.oakでサーバーが立ち上がる事の確認
生Denoではなくoakを使おうと思うのでoak
での動作テストもしてみましょう。
import { Application } from "https://deno.land/x/oak/mod.ts";
const app = new Application();
// Logger
app.use(async (ctx, next) => {
await next();
const rt = ctx.response.headers.get("X-Response-Time");
console.log(`${ctx.request.method} ${ctx.request.url} - ${rt}`);
});
// Timing
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
ctx.response.headers.set("X-Response-Time", `${ms}ms`);
});
// Hello World!
app.use((ctx) => {
ctx.response.body = "Hello World!";
});
await app.listen({ port: 80 });
denoはTop level awaitが効いているのでasync
で包んであげる必要はありません。
ES2022も同様にTop level awaitが有効です。
起動してアクセスしてログが出ることを確認
[yamamoto@deno deno1_28_1-oak]$ authbind --deep deno run --allow-net main.ts
GET http://localhost/ - 0ms
GET http://localhost/favicon.ico - 0ms
本題
要件・機能を考える
特に作りたいものが無いので認証とメモの二機能作ろうと思います。進捗次第ではメモだけになる可能性もあります。
時間の都合で下記の3つの実装方法の紹介のみとなりました。
- ユーザーに紐付くメモ(の取得)
- ID/PW認証
- 任意のルートに仕込むミドルウェアの作り方
Ushio Architecture 1(php)を参考にしつつ自由に作りたいと思います。
実装
の前に
この記事は実際にコマンドを打ちながらコーディングしながら調べながら書いています。
その為記事に掲載しているコードと実際のコードに差異がある場合もあります。極力差異の無いようコード修正時は記事に掲載するコードも併せて修正するように気を付けますが、真似てみてもしも手元で動かない場合はコードをよしなに修正して試してください。
1.import_mapを作る
まずは必要なモジュールをdenoland xから探します。探すと言っても検索に掛けるだけです。
機能から考えるとoakの他にはsqliteがあれば足りるでしょう。mysqlやO/Rマッパーもあります。
{
"imports": {
"encoding/": "https://deno.land/std@0.166.0/encoding/",
"log/": "https://deno.land/std@0.166.0/log/",
"datetime/": "https://deno.land/std@0.166.0/datetime/",
"uuid/": "https://deno.land/std@0.166.0/uuid/",
"oak/": "https://deno.land/x/oak@v11.1.0/",
"sqlite/": "https://deno.land/x/sqlite@v3.7.0/"
}
}
2.エントリーポイントを作る
//system
import { Application, Router } from "oak/mod.ts";
const router = new Router();
router.get("/", (context: any) => {
context.response.body = "Hello world! ";
})
router.get("/2", (context: any) => {
context.response.body = "Hell world! ";
})
const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());
await app.listen({ port: 80 });
http://localhost/、http://localhost/2にアクセスして動作確認する。
容易く二つのルーティングを作る事が出来ました。
現在のディレクトリ構成
.
├── .tool-versions
├── .vscode
│ ├── launch.json
│ └── settings.json
├── import_map.json
└── main.ts
3.Contextの定義
contextは和訳すると 背景・前後・脈絡・文脈・属性など出てきますが、プログラムにおけるcontextは文脈です。
今回作る機能は認証認可とメモと決めたのでその二つのcontextディレクトリを作ります。
mkdir -p ./contexts/account
mkdir ./contexts/memo
4.レイヤーと概念(役割/機能)の定義
コンテキスト配下のレイヤーはDomain / Infrastructure / Application / (Web)UI
など考えられます。なお概念とはEntity / Repository
などを指します。
mkdir -p ./contexts/account/{domain,infrastructure,use_case}
mkdir -p ./contexts/account/domain/{entity,exception,persistence}
mkdir -p ./contexts/account/infrastructure/{persistence,presenter}
mkdir -p ./contexts/account/application/use_case
mkdir -p ./contexts/account/application/controller
mkdir -p ./contexts/memo/{domain,infrastructure,use_case}
mkdir -p ./contexts/memo/domain/{entity,exception,persistence}
mkdir -p ./contexts/memo/infrastructure/{persistence,presenter}
mkdir -p ./contexts/memo/application/use_case
mkdir -p ./contexts/memo/application/controller
5.メモのEntityを作る
クリーンアーキテクチャ(The Clean Architecture翻訳)によるとEntityは
エンティティは、メソッドを持ったオブジェクトかもしれない、あるいは、データ構造と関数の集合かもしれない。
との事ですが、Ushio Architectureは後者です。
また、レイヤー越えるデータは
典型的には、境界をまたがるデータは、シンプルなデータ構造だ。基本的な構造体や、シンプルなデータ転送オブジェクト(Data Transfer object)を好みに応じて使うことができる。
とも書いてありますね。
脳内設計で早速作りましょう。typescript
は型定義が出来るのでEntityとTypeを別々(前者)で作ってみます。ついでにコマンドラインから簡単に試せるようにimport.meta.main
も追加しましょう。pythonで言うところの__name__ == '__main__'
です。
ボブが言うにはEnterprise wideなビジネスロジックはEntityに、アプリケーションの固有のビジネスロジックはUseCaseに書くというルールとの事ですが、Enterprise Wideをどう和訳すれば良いのか分からず責務の判断がつかないので、Ushio Architectureを参考に雰囲気で作ります。
後でメソッドに手を加えるでしょうが、ひとまずこんな感じです。
//type Constructor<T> = { new (): T };
export type Id = number;
export type UserId = number;
export type Content = string;
export type LockNo = number;
export type Updater = string;
export type Updated = Date;
export type Data = {
id: Id|undefined,
userId: UserId,
content: Content,
lockNo: number,
updater: string,
updated?: Date,
//created?: Timestamp
};
export class Memo {
private data: Data;
private constructor(data: Data)
{
this.data = data;
}
/*
static duplicate<T>(
this: Constructor<T>,
): Memo[] {
return [];
}
*/
static async create<T extends MemoRepository>(data:Data, memoRepository: T): Promise<Memo|false>//deno 1.28のlinterではnewが使えないアピールしてくるので
{
const result = await memoRepository.create(data);
if (result === false) {
return new Promise( (resolve) => resolve(false) );
} else {
return new Promise( (resolve) => resolve(new this(result)) );
}
}
static restore(data: Data): Memo {
try{
if ('id' in data === false) {
throw new Error("id undefined");
}
if ('lockNo' in data === false) {
throw new Error("lockNo undefined");
}
if ('updated' in data === false) {
throw new Error("updated undefined");
}
} catch(e) {
throw new Error(e);
}
return new this(data);
}
getData (): Data{
return this.data;
}
}
if (import.meta.main) {
const restore = Memo.restore()
console.log(restore);
const create = Memo.create(
12,//userid
"content",//content
2,//lockno
"memo.restore"//updater
);
console.log(create);
console.log(create.getData());
}
6.repositoryを作る
先に挙げた参考サイトの引用元によると
リポジトリのインターフェースはEnterprise Business Rulesで実装されなければなりません。しかし、Application Business Rulesでしか利用しないリポジトリであれば、そちらでインターフェースを実装することも可能です。(TODO: 実装例について、ドメイン層とusecase層それぞれでどのようにリポジトリを実装しているか確認)
との事です。
今回の構成だとapplication
配下かdomain
配下のどっちに置くかって話ですね。では作ります。
リポジトリーのインターフェイス
import {Id, UserId, Data} from "../entity/memo.ts";
export interface MemoRepository {
get(id: Id): Promise<Data|false>;
getByUserId(userId: UserId): Promise<Array<Data>>;
getAll(): Promise<Array<Data>>;
create(memo: Data): Promise<Data|false>;
update(memo: Data): Promise<boolean>;
}
Wiki見たところ
非同期イベント駆動型プラットフォームとしてlibuv(英語版)に代わってTokioが導入され
と書いてあるのでDenoもnodejs同様にノンブロッキングI/OなのでPromise
で作りましょう。Promise包むだけなので簡単ですね、簡単に使えるものは何でも使いましょう。
今回はPromise
にする意味は無いです。
掲載コードを真似たらもしかするとスピードマニアに無駄なオーバーヘッドだと指摘されるかもしれません。指摘であれば根拠と証拠のデータが伴うはずなので、何がどういう理由で何と比べてどの程度遅くなるのかを質問してみましょう。レベルアップのチャンスです。(教えてもらえれば)
リポジトリーの実装の前に
実装する前にはまずデータの保存先が必要です。sqliteでMemoテーブル作りましょう。Entityに対応するテーブル定義でOKです。
deno migration
で検索したらdenolandのnessieが真っ先に引っかかっりました。githubのStarsとコミットログを見た感じ使っても良さそうな気がするので調べながら使っちゃいましょう。
deno install --unstable --allow-net=localhost --allow-read=. --allow-write=nessie.config.ts,db -f https://deno.land/x/nessie/cli.ts
deno run -A --unstable https://deno.land/x/nessie/cli.ts init --mode config --dialect sqlite
mkdir -p ./db/migrations
mkdir -p ./db/seeds
deno run -A --unstable https://deno.land/x/nessie@2.0.10/cli.ts make create_memos
マイグレーションファイルの修正
割愛
マイグレーション
$ deno run -A --unstable https://deno.land/x/nessie/cli.ts migrate
#rollback
$ deno run -A --unstable https://deno.land/x/nessie/cli.ts rollback
DB確認
$ sqlite3 sqlite.db
sqlite> .tables
sqlite> .schema memos
CREATE TABLE memos (id integer primary key, user_id integer, content text, lock_no integer, updater text, created timestamp, updated timestamp default (datetime(CURRENT_TIMESTAMP,'localtime')));
sqlite> insert into memos (user_id, content, lock_no, updater) values (100, '試験データ', 1, 'sqlite client');
sqlite> insert into memos (user_id, content, lock_no, updater) values (101, '時間がピンチ', 1, 'sqlite client');
sqlite> insert into memos (user_id, content, lock_no, updater) values (101, '時間がない!', 1, '手動インサート');
sqlite> select * from memos;
1|100|テ試験データ|1|sqlite client||2022-11-27 11:45:19
マイグレーションしない場合はDB確認に乗せたSQL参考にしてください。
リポジトリーの実装
denoのモジュールのexampleを参考にしながら作ります。
普通に作るともうアドベントカレンダーの公開日に間に合わなさそうなので、これ以降の作業は決め打ちしたり共通化しなかったりで作ります。
import * as log from "log/mod.ts";
import { DB } from "sqlite/mod.ts";
import { Data, Id, UserId } from "../../domain/entity/memo.ts";
import { MemoRepository } from "../../domain/persistence/memo_repository.ts";
type SelectColumns = {
id: number,
user_id: number,
content: string,
lock_no: number,
updater: string,
updated: Date,
};
type InsertPlaceholder = {
userId: number,
content: string,
lockNo: number,
updater: string,
}
export class MemoRepositoryImplSqlite implements MemoRepository {
async get(id: Id): Promise<Data|false> {
try {
const db = new DB("sqlite.db");
const query = db.prepareQuery<
[number, number, string, number, string, Date],
SelectColumns
>("SELECT id, user_id, content, lock_no, updater, updated FROM memos WHERE id = :id");
//const query = db.prepareQuery("SELECT id, user_id, content, lock_no, updater, updated FROM memos WHERE id = :id");
const rows = query.allEntries({ id });
if (rows.length !== 1) {
log.warning("Finded rows count 0 or greater than 2");
return new Promise((resolve)=>resolve(false));
} else {
log.info("Finded rows count 1");
//const row: Columns = rows[0] as unknown as Columns;
const row = rows[0];
const result: Data = {
id: row['id'],
userId: row['user_id'],
content: row['content'],
lockNo: row['lock_no'],
updater: row['updater'],
updated: row['updated']
};
log.info(result);
return new Promise((resolve)=>resolve(result));
}
} catch (e) {
log.error(`Exception ${e}`);
throw new Error(e.message);
//throw new Error(e.code);
//throw new Error(e.codeName);
}
}
async getByUserId(userId: UserId): Promise<Array<Data>> {
//実装済み
}
async getAll(): Promise<Array<Data>> {
//実装済み
}
async create(memo: Data): Promise<Data|false> {
//実装済み
}
async update(memo: Data): Promise<boolean> {
throw new Error("Method not implemented.");
}
}
if (import.meta.main) {
// deno run --importmap import_map.json --allow-all ./contexts/memo/infrastructure/persistence/memo_repository_impl_sqlite.ts
(async ()=> {
const repo = new MemoRepositoryImplSqlite();
if (false) {
//get test
const res = await repo.get(1);
log.debug(res);
const res2 = await repo.get(2);
log.debug(res2);
const res3 = await repo.get(100);
log.debug(res3);
}
if (false) {
//getByUserId test
const list = await repo.getByUserId(100);
log.debug(list);
const list2 = await repo.getByUserId(101);
log.debug(list2);
}
if (true) {
//create test
const data: Data = {
userId: 789,
content: "ResultをInsertDataに",
updater: "Command line"
};
const createResult = await repo.create(data);
log.error(createResult);
}
if (true) {
//getAll test
const all = await repo.getAll();
log.critical(all);
}
})();
}
記載のソースを参考にgetAll
・getByUserId
・create
を実装して下さい。以降の工程で使います。
動作確認
$ deno run --importmap import_map.json --allow-all ./contexts/memo/infrastructure/persistence/memo_repository_impl_sqlite.ts
~~省略~~
INFO [{"id":2,"userId":101,"content":"時間がピンチ","lockNo":1,"updater":"sqlite client","updated":"2022-11-27 15:23:27"},{"id":3,"userId":101,"content":"時間がない!","lockNo":1,"updater":"手動インサー ト","updated":"2022-11-27 15:52:31"}]
DEBUG [{"id":2,"userId":101,"content":"時間がピンチ","lockNo":1,"updater":"sqlite client","updated":"2022-11-27 15:23:27"},{"id":3,"userId":101,"content":"時間がない!","lockNo":1,"updater":"手動インサート","updated":"2022-11-27 15:52:31"}]
7.usecaseを考える
usecaseはインターフェイスを用意するものらしいですが、今回は用意しません。
各コンテキストで考えられるusecaseは下記でしょう。
- アカウント
-
作成(登録)(store) ※今回は作らない -
認証(authentication) ※今回は作れない(時間が) -
認可(authorization) ※今回は作れない(時間が) -
削除(delete) ※今回は作らない
-
- メモ
- 一覧 (list)
- 新規作成 (store)
-
更新(update) ※今回は作れない(時間が) -
削除(delete) ※今回は作れない(時間が)
usecaseに合わせたディレクトリを作成する
mkdir ./contexts/account/application/use_case/{store,authentication,authorization,delete}
mkdir ./contexts/memo/application/use_case/{list,store,update,delete}
メモ一覧表示用のusecaseの使い道
実装(interactor)とinputとoutputを作ります。
$ touch ./contexts/memo/application/use_case/list/{input,interactor,output}.ts
$ touch ./contexts/memo/application/use_case/store/{input,interactor,output}.ts
inputの実装
import { UserId } from "../../../domain/entity/memo.ts";
export type Input = {
userId: UserId
};
export function input(params: any): Input {
return { userId: params.user_id};
}
interactorの実装
oakには手軽にDI(constructor dependency injection
)を実現する方法は無さそうです。比較的簡単そうなのはtsyringeを取り入れる事でしょうか。今回は出来るだけあの円の外側から注入する事だけ意識します。クラスである必要も無いのでfunction
で定義します。
単発で動かせるコードも書きます。
import { Memo } from "../../../domain/entity/memo.ts";
import { MemoRepository } from "../../../domain/persistence/memo_repository.ts";
import { Input } from "./input.ts";
export async function execute<T extends MemoRepository>(input: Input, memoRepository: T): Promise<Array<Memo>> {
return (await memoRepository.getByUserId(input.userId)).map( (data)=> Memo.restore(data));
}
import { MemoRepositoryImplSqlite } from "../../../infrastructure/persistence/memo_repository_impl_sqlite.ts";
if (import.meta.main) {
(async()=>{
const input:Input = {
userId: 101
};
const repo = new MemoRepositoryImplSqlite();
await execute(input, repo);
})();
}
outputの実装
import { Id, UserId, Content, Memo } from "../../../domain/entity/memo.ts";
export type Output = {
id: Id
userId: UserId
content: Content
};
export function output(memos: Array<Memo>): Array<Output> {
const output: Array<Output> = [];
memos.forEach((v: Memo)=> output.push(
{
id: v.getData().id as Id,
userId: v.getData().userId as UserId,
content: v.getData().content as Content,
}
));
return output;
}
if (import.meta.main) {
(async()=>{
const memo1 = Memo.restore({
id: 4,
userId: 2,
content: "Content",
lockNo: 22,
updater: "string",
updated: new Date(),
});
const memo2 = Memo.restore({
id: 10,
userId: 2,
content: "コンテンツ",
lockNo: 22,
updater: "string",
updated: new Date(),
});
const input: Array<Memo> = [memo1, memo2];
const result = await output (input);
console.log(result);
})();
}
8.controllerを用意する
インバウンドを受け取り各ユースケースへ渡して返してもらう役割。Enterprise wideなバリデーションもここです。
- APIの場合はjsonで入ってjsonで出ていく
- ブラウザーの場合はフォームによるPOSTなどで入って来てhtmlで出ていく
という事は、api/xxx_controller
とweb/xxx_controller
を用意すれば良いと考える事が出来ます。
mkdir ./contexts/account/application/controller/{api,web}
touch ./contexts/account/application/controller/api/{store,authentication,authorization,delete}_controller.ts
touch ./contexts/account/application/controller/web/{store,authentication,authorization,delete}_controller.ts
mkdir ./contexts/memo/application/controller/{api,web}
touch ./contexts/memo/application/controller/api/{list,store,update,delete}_controller.ts
touch ./contexts/memo/application/controller/web/{list,store,update,delete}_controller.ts
早速実装します。ここでやっとoakが絡んできます。
import {Status, helpers} from "oak/mod.ts";
import * as log from "log/mod.ts";
import { Response, ResponseBody } from "../../../../../common/response.ts";
import { input } from "../../use_case/list/input.ts";
import { output } from "../../use_case/list/output.ts";
import { execute } from "../../use_case/list/interactor.ts";
import { MemoRepositoryImplSqlite } from "../../../infrastructure/persistence/memo_repository_impl_sqlite.ts";
export default async function controller(ctx: any) {
const memoRepository = new MemoRepositoryImplSqlite();
const inbound = ctx.params;
//Todo: validate(inbound)
const outbound = output(await execute(input(inbound), memoRepository));
}
引数のctx
がoakが定義する型です。
ex1.レスポンス生成用の共通処理を作る
レスポンスのフォーマットを統一する為にひとつクラスを作ります。
$ mkdir -p ./common
$ touch ./common/response.ts
export type StatusCode = number;
export type StatusName = string;
export type ResponseMessage = string;
export type ResponseType = string;//json,html,text,other...
export type ResponseData = {
status: StatusName,
message: ResponseMessage,
data: object,
};
export type Status = number;
export type Type = string;
export class ResponseBody {
private status!: StatusName;
private message!: ResponseMessage;
private data!: object;
getData(): ResponseData{
return {
status: this.status,
message: this.message,
data: this.data,
}
}
static create(data: ResponseData){
const newSelf = new ResponseBody();
newSelf.status = data.status;
newSelf.message = data.message;
newSelf.data = data.data;
return newSelf
}
}
export class Response {
private status!: StatusCode;
private type!: ResponseType;
private body!: ResponseData;
getStatus() {
return this.status;
}
getType() {
return this.type;
}
getBody() {
return this.body;
}
static create(
{status, type, body}: {status: StatusCode, type: ResponseType, body: ResponseData}
): Response{
const newSelf = new Response();
newSelf.status = status;
newSelf.type = type;
newSelf.body = body;
return newSelf;
}
}
9.presenterを作るらない
役割は前述の通りです。時間の都合で今回は省略します。とは言うものの役割の重さ的にapiとwebの各コントローラー内部に書いても問題無いでしょう。早速コントローラーを作り変えます。
ここでもoakが絡んで来ます。
oakのテストの書き方を調べてみたところ、こんな感じで書けそうなので適当に引用して作ってみます。
ログは出してませんがリポジトリーのソースに書かれたロガーが動いてくれるのでそれで確認する事としましょう。
import {Status} from "oak/mod.ts";
import { Response, ResponseBody } from "../../../../../common/response.ts";
import { input } from "../../use_case/list/input.ts";
import { output } from "../../use_case/list/output.ts";
import { execute } from "../../use_case/list/interactor.ts";
import { MemoRepositoryImplSqlite } from "../../../infrastructure/persistence/memo_repository_impl_sqlite.ts";
export default async function controller(ctx: any) {
const memoRepository = new MemoRepositoryImplSqlite();
const inbound = ctx.params;
//Todo: validate(inbound)
const outbound = output(await execute(input(inbound), memoRepository));
const responseBody = ResponseBody.create({
status: "処理結果のステータスコード(任意)",
message: "処理結果を示すメッセージ",
data: outbound
});
const response = Response.create({
status: Status.OK,
type: "json",
body: responseBody.getData()
});
ctx.response.status = response.getStatus();
ctx.response.type = response.getType();
ctx.response.body = response.getBody();
return ;
}
import { testing } from "oak/mod.ts";
if (import.meta.main) {
(async()=>{
const ctx = testing.createMockContext({
path: "/memo/list",
params: {
user_id: "101"
}
});
await controller(ctx);
const ctx2 = testing.createMockContext({
path: "/memo/list",
params: {
user_id: "101"
}
});
await controller(ctx2);
})();
}
動作確認してみましょう
$ deno run --importmap import_map.json --allow-all ./contexts/memo/application/controller/api/list_controller.ts
INFO Function getByUserId
INFO result
INFO [{"id":1,"userId":100,"content":"試験データ","lockNo":1,"updater":"sqlite client","updated":"2022-11-27 15:22:20"}]
INFO Function getByUserId
INFO result
INFO [{"id":2,"userId":101,"content":"時間がピンチ","lockNo":1,"updater":"sqlite client","updated":"2022-11-27 15:23:27"},{"id":3,"userId":101,"content":"時間がない!","lockNo":1,"updater":"手動インサー ト","updated":"2022-11-27 15:52:31"}]
OK、動きました。
ところで、これらのテストコードはDenoのtestを使って書く事も出来ます。その場合はvscode上でワンクリックでテストをする事も出来ます。
10.ルーティングを設定する
この辺りからさらにoak
成分多めの領域です。
accountのルーティングを考える。
考えた結果
input | path | method | controller |
---|---|---|---|
api | account/list | get | list_controller.ts |
api | account/store | post | store_controller.ts |
api | account/authentication | post | authentication_controller.ts |
api | account/authorization | post | authorization_controller.ts |
web | account/list | get | list_controller.ts |
web | account/store | post | store_controller.ts |
web | account/authentication | post | authentication_controller.ts |
web | account/authorization | post | authorization_controller.ts |
memoのルーティングも考える
考えた結果
input | path | method | controller |
---|---|---|---|
api | memo/list | get | list_controller.ts |
api | memo/store | post | store_controller.ts |
api | memo/update | post | update_controller.ts |
api | memo/delete | post | delete_controller.ts |
web | memo/list | get | list_controller.ts |
web | memo/store | post | store_controller.ts |
web | memo/update | post | update_controller.ts |
web | memo/delete | post | delete_controller.ts |
ルーティングファイルを用意する。
mkdir ./routes/
touch ./routes/api.ts
touch ./routes/web.ts
時間的制約でメモの全機能を作れる気がしないのでパス/memo
だけ定義します。
import { Router } from "oak/mod.ts";
import memoListController from "../contexts/memo/application/controller/api/list_controller.ts";
const router = new Router();
router.get("/memo", memoListController);
export default router;
エントリーポイントを修正する
サーバーを起動する時のファイルです。序盤でoakの動作確認した時のものです。
import { Application, Router } from "oak/mod.ts";
import api from "./routes/api.ts";
const app = new Application();
app.use(api.routes());
app.use(api.allowedMethods());
await app.listen({ port: 80 });
起動して
$ authbind --deep deno run --importmap import_map.json --allow-all main.ts
アクセスすると
$ curl -s http://localhost/memo?user_id=101|jq
user_idで指定したユーザーのメモがjsonで返って
{
"status": "000",
"message": "Successed",
"data": []
}
来ません。
調べてみたところ、どうやら今のcontrollerの書き方ではクエリーパラメーターを受け取れない事が分かりました。
早速修正します。
export default async function controller(ctx: any) {
const memoRepository = new MemoRepositoryImplSqlite();
const inbound = helpers.getQuery(ctx);
const outbound = output(await execute(input(inbound), memoRepository));
const responseBody = ResponseBody.create({
status: "000",//システムで定義したいであろうコード
message: "Successed",//Statusを言葉で端的に説明したもの
data: outbound
});
const response = Response.create({
status: Status.OK,
type: "json",
body: responseBody.getData()
});
ctx.response.status = response.getStatus();
ctx.response.type = response.getType();
ctx.response.body = response.getBody();
return ;
}
もう一度アクセスすると
$ curl -s http://localhost/memo?user_id=101|jq
jsonが返って来ます。
{
"status": "000",
"message": "Successed",
"data": [
{
"id": 2,
"userId": 101,
"content": "時間がピンチ"
},
{
"id": 3,
"userId": 101,
"content": "時間がない!"
}
]
}
他人のuser_idさえ指定すれば勝手に他人のメモを見る事が出来るロクでもないAPIの完成です。
参考までに、ここまでの時点でのツリー構造は下記の通りです。
tree
.
├── common
│ └── response.ts
├── contexts
│ ├── account
│ │ ├── application
│ │ │ ├── controller
│ │ │ │ ├── api
│ │ │ │ │ ├── authentication_controller.ts
│ │ │ │ │ ├── authorization_controller.ts
│ │ │ │ │ ├── delete_controller.ts
│ │ │ │ │ └── store_controller.ts
│ │ │ │ └── web
│ │ │ │ ├── authentication_controller.ts
│ │ │ │ ├── authorization_controller.ts
│ │ │ │ ├── delete_controller.ts
│ │ │ │ └── store_controller.ts
│ │ │ └── use_case
│ │ │ ├── authentication
│ │ │ ├── authorization
│ │ │ ├── delete
│ │ │ └── store
│ │ ├── domain
│ │ │ ├── entity
│ │ │ ├── exception
│ │ │ └── persistence
│ │ └── infrastructure
│ │ ├── persistence
│ │ └── presenter
│ └── memo
│ ├── application
│ │ ├── controller
│ │ │ ├── api
│ │ │ │ ├── delete_controller.ts
│ │ │ │ ├── list_controller.ts
│ │ │ │ ├── store_controller.ts
│ │ │ │ └── update_controller.ts
│ │ │ └── web
│ │ │ ├── delete_controller.ts
│ │ │ ├── list_controller.ts
│ │ │ ├── store_controller.ts
│ │ │ └── update_controller.ts
│ │ └── use_case
│ │ ├── delete
│ │ ├── list
│ │ │ ├── input.ts
│ │ │ ├── interactor.ts
│ │ │ └── output.ts
│ │ ├── store
│ │ │ ├── input.ts
│ │ │ ├── interactor.ts
│ │ │ └── output.ts
│ │ └── update
│ ├── domain
│ │ ├── entity
│ │ │ └── memo.ts
│ │ ├── exception
│ │ └── persistence
│ │ └── memo_repository.ts
│ └── infrastructure
│ ├── persistence
│ │ └── memo_repository_impl_sqlite.ts
│ └── presenter
│ ├── html.ts
│ └── json.ts
├── db
│ ├── migrations
│ │ └── 20221127111118_create_memos.ts
│ └── seeds
├── import_map.json
├── log.txt
├── main.ts
├── nessie.config.ts
├── routes
│ ├── api.ts
│ └── web.ts
└── sqlite.db
ex2.不変にする
実は今のentityの実装、php感覚だと一見privateにカプセル化されているように見えますが全くの無意味です。entityに実装したgetDataも何の意味もありません。メソッドとデータ型を別で定義した意味もありません。これを直します。
プライベートフィールドを用いればphp感覚で作っても意図通りに動作してくれるかもしれませんが、未確認なので今回は使いません。
createメソッド修正
static async create<T extends MemoRepository>(data:Data, memoRepository: T): Promise<Memo|false>//deno 1.28のlinterではnewが使えないアピールしてくるので
{
const result = await memoRepository.create(data);
if (result === false) {
return new Promise( (resolve) => resolve(false) );
} else {
return new Promise( (resolve) => resolve(new this(result)) );
}
}
static async create<T extends MemoRepository>(data:Data, memoRepository: T): Promise<Data|false>//deno 1.28のlinterではnewが使えないアピールしてくるので
{
const result = await memoRepository.create(data);
if (result === false) {
return new Promise( (resolve) => resolve(false) );
} else {
return new Promise( (resolve) => resolve(Object.freeze(result)) );
}
}
restoreを検証用メソッドに変更
static restore(data: Data): Memo {
try{
if ('id' in data === false) {
throw new Error("id undefined");
}
if ('lockNo' in data === false) {
throw new Error("lockNo undefined");
}
if ('updated' in data === false) {
throw new Error("updated undefined");
}
} catch(e) {
throw new Error(e);
}
return new this(data);
}
/**
* DBが返したデータである事を検証し、正当であれば不変にして返す
*
* @param data
* @returns
*/
static validate(data: Data): Data {
try{
if ('id' in data === false) {
throw new Error("id undefined");
}
if ('lockNo' in data === false) {
throw new Error("lockNo undefined");
}
if ('updated' in data === false) {
throw new Error("updated undefined");
}
} catch(e) {
throw new Error(e);
}
return Object.freeze(data);
}
これでプライベートフィールドもコンストラクターも不要になり且つイミュータブル(不変) になりました。メソッドと型の分離完了です。この修正に伴って関係各ファイルも修正が必要です。直しましょう。
関係する箇所
//~省略~
async get(id: Id): Promise<Data|false> {
log.info("Function get");
try {
const db = new DB("sqlite.db");
const query = db.prepareQuery<
[number, number, string, number, string, Date],
SelectColumns
>("SELECT id, user_id, content, lock_no, updater, updated FROM memos WHERE id = :id");
//const query = db.prepareQuery("SELECT id, user_id, content, lock_no, updater, updated FROM memos WHERE id = :id");
const rows = query.allEntries({ id });
if (rows.length !== 1) {
log.warning("Finded rows count 0 or greater than 2");
return new Promise((resolve)=>resolve(false));
} else {
log.info("Finded rows count 1");
//const row: Columns = rows[0] as unknown as Columns;
const row = rows[0];
log.info("rows");
log.info(row);
const result: Data = {
id: row['id'],
userId: row['user_id'],
content: row['content'],
lockNo: row['lock_no'],
updater: row['updater'],
updated: row['updated']
};
log.info("result");
log.info(result);
return new Promise((resolve)=>resolve(Object.freeze(result) ));
}
} catch (e) {
log.error(`Exception ${e}`);
throw new Error(e.message);
//throw new Error(e.code);
//throw new Error(e.codeName);
}
}
async getByUserId(userId: UserId): Promise<Array<Data>> {
log.info("Function getByUserId");
try {
const db = new DB("sqlite.db");
const query = db.prepareQuery<
[number, number, string, number, string, Date],
SelectColumns
>("SELECT id, user_id, content, lock_no, updater, updated FROM memos WHERE user_id = :userId");
const rows = query.allEntries({ userId });
const result: Array<Data> = rows.map((row)=>{
return {
id: row['id'],
userId: row['user_id'],
content: row['content'],
lockNo: row['lock_no'],
updater: row['updater'],
updated: row['updated']
}
});
log.info("result");
log.info(result);
const freezedResult = result.map((row)=>Memo.validate(row));
return new Promise((resolve)=>resolve(freezedResult));
} catch (e) {
log.error(`Exception ${e}`);
throw new Error(e.message);
}
}
//~省略~
//before
export async function execute<T extends MemoRepository>(input: Input, memoRepository: T): Promise<Array<Data>> {
return (await memoRepository.getByUserId(input.userId)).map( (data)=> Memo.validate(data));
}
//after
export async function execute<T extends MemoRepository>(input: Input, memoRepository: T): Promise<Array<Data>> {
return await memoRepository.getByUserId(input.userId);
}
11.認証関連を作る
それではロクでもない状態を直す為にも認証を作ります。
各レイヤーの説明はここまでの工程で済んでるので省略しますが、やる事は
- accountsテーブルを作る
- accountsのレコードを用意
- 認証を作る
- 認可を作る
- 任意のルートに差し込めるミドルウェアにする
- メモのルーティングに差し込む
です。
厄介そうなのはミドルウェアにする所ですが、何とかなりそうです。(https://oakserver.github.io/oak/)
新しい要素はミドルウェアにする点とそれをルーティングに差し込む点の二つだけです。それ以外についてはここまでの説明で足りていると思いますし設計も言語に依らず共通だと思うので解説は雑に、コードもほとんど省略でいきます。2
accountsテーブルの作成
前述のマイグレーションでもCREATE
直打ちでも何でも良いので、Seq/ID/PW
のカラムを含めて作りましょう。
accountsのレコードを用意
ユーザー情報を適当にINSERTします。今回のところはPWは平文にしておきます。
sqlite> insert into accounts (user, password) values ('yamamoto', 'yamapass');
sqlite> insert into accounts (user, password) values ('umimoto', 'umipass');
sqlite> select * from accounts;
1|yamamoto|yamapass||||2022-12-01 00:25:10
2|umimoto|umipass||||2022-12-01 00:25:35
Denoland.xにはbcryptを作れるモジュールがあります。公開するサービスを開発する時は最低限これを使いましょう。
認証を作る
ID/PWを検証し、正当であればセッションやJWTやアクセストークンを返す機能を作ります。
domain layer
認証用エンティティ
import { AccountRepository } from "../persistence/account_repository.ts";
export type Id = number;
export type User = string;
export type Password = string;
export type LockNo = number;
export type Updater = string;
export type Updated = Date;
export type Data = {
id?: Id,
user: User,
password: Password,
lockNo?: number,
updater: string,
updated?: Date,
created?: Date
};
export class Account {
static async create<T extends AccountRepository>(data:Data, AccountRepository: T): Promise<Data|false>
{
const result = await AccountRepository.create(data);
if (result === false) {
return new Promise( (resolve) => resolve(false) );
} else {
return new Promise( (resolve) => resolve(Object.freeze(result)) );
}
}
static validate(data: Data): Data {
try{
if ('id' in data === false) {
throw new Error("id undefined");
}
if ('lockNo' in data === false) {
throw new Error("lockNo undefined");
}
if ('updated' in data === false) {
throw new Error("updated undefined");
}
} catch(e) {
throw new Error(e);
}
return Object.freeze(data);
}
}
認可用エンティティ
export type User = string;
export type AccessToken = string;
export type Expired = number;
export type Data = {
user: User,
accessToken: AccessToken
expired: Expired
};
export class Authentication {
static create(user:User, expired: Expired = 0): Data
{
const accessToken = "本当は発行する処理が必要";//作りましょう
const result = {
user,
accessToken,
expired
};
return Object.freeze(result);
}
static validate(data: Data): boolean {
return true;//真面目なバリデーションを作りましょう
}
}
リポジトリーインターフェイス
import {Id, User, Data, Password} from "../entity/user.ts";
export interface AccountRepository {
get(id: Id): Promise<Data|false>;
getByUserPassword(user: User, password: Password): Promise<Data|false>;
getAll(): Promise<Array<Data>>;
create(account: Data): Promise<Data|false>;
update(account: Data): Promise<boolean>;
}
infrastructure layer
リポジトリー
export class AccountRepositoryImplSqlite implements AccountRepository {
//実装してください。
}
if (import.meta.main) {
// deno run --importmap import_map.json --allow-all ./contexts/account/infrastructure/persistence/account_repository_impl_sqlite.ts
(async ()=> {
const user = await repo.getByUserPassword("yamamoto", "yamapass");
log.debug(user);
if (user === false) {
log.debug("ユーザー見つからない");
} else {
log.debug("ユーザー見つかった");
user.id = 1;
log.debug("この上でエラーになる");
}
})();
}
正しく実装できれば以下のようなログが出力されます
$ deno run --importmap import_map.json --allow-all ./contexts/account/infrastructure/persistence/account_repository_impl_sqlite.ts
DEBUG {"id":1,"user":"yamamoto","password":"yamapass","lockNo":null,"updater":null,"updated":"2022-12-01 00:25:10"}
DEBUG ユーザー見つかった
error: Uncaught (in promise) TypeError: Cannot assign to read only property 'id' of object '#<Object>'
user.id = 1;
^
at file:///home/yamamoto/src/deno1_28_1-oak/contexts/account/infrastructure/persistence/account_repository_impl_sqlite.ts:169:24
application layer
インプット
import { User, Password } from "../../../domain/entity/account.ts";
export type Input = {
user: User,
password: Password
};
export function input(params: any): Input {
return { user: params.user_id, password: params.password};
}
インタラクター
import * as log from "log/mod.ts";
//import { Data } from "../../../domain/entity/account.ts";
import { Authentication, Data as Token } from "../../../domain/entity/authentication.ts";
import { AccountRepository } from "../../../domain/persistence/account_repository.ts";
import { Input } from "./input.ts";
export async function execute<T extends AccountRepository>(input: Input, accountRepository: T): Promise<Token|false> {
const authResult = await accountRepository.getByUserPassword(input.user, input.password);
if ( authResult !== false) {
return Authentication.create(authResult.user);
} else {
return false;
}
}
import { AccountRepositoryImplSqlite } from "../../../infrastructure/persistence/account_repository_impl_sqlite.ts";
if (import.meta.main) {
(async()=>{
//成功
const input:Input = {
user: "yamamoto",
password: "yamapass"
};
const repo = new AccountRepositoryImplSqlite();
log.critical( await execute(input, repo));
//失敗
const input2:Input = {
user: "yamamochi",
password: "yamapass"
};
const repo2 = new AccountRepositoryImplSqlite();
log.critical( await execute(input2, repo2));
})();
}
動作確認
$ deno run --importmap import_map.json --allow-all ./contexts/account/application/use_case/authorization/interactor.ts
INFO {"id":1,"user":"yamamoto","password":"yamapass","lock_no":null,"updater":null,"updated":"2022-12-01 00:25:10","created":null}
CRITICAL {"user":"yamamoto","accessToken":"本当は発行する処理が必要","expired":0}
INFO Function getByUserPassword
WARNING Finded rows count 0 or greater than 2
CRITICAL false
アウトプット
import { AccessToken, Expired, Data } from "../../../domain/entity/authentication.ts";
export type Output = {
accessToken: AccessToken
expired: Expired
};
export function output(output: Data): Output {
return output;
}
認証コントローラー
import {Status} from "oak/mod.ts";
import * as log from "log/mod.ts";
import { Response, ResponseBody } from "../../../../../common/response.ts";
import { input } from "../../use_case/authorization/input.ts";
import { output } from "../../use_case/authorization/output.ts";
import { execute } from "../../use_case/authorization/interactor.ts";
import { AccountRepositoryImplSqlite } from "../../../infrastructure/persistence/account_repository_impl_sqlite.ts";
export default async function controller(ctx: any) {
const accountRepository = new AccountRepositoryImplSqlite();
const result = ctx.request.body(); // content type automatically detected
if (result.type !== "form") {
throw new Error ("form以外許可しません");
}
const value = await result.value; // an object of parsed JSON
const user = await value.get('user');
const password = await value.get('password');
const inbound = {user, password};
const authResult = await execute(input(inbound), accountRepository);
const outbound = (() => {
if (authResult !== false) {
return output(authResult);
} else {
//ちょっと手抜きで
return {"res":"error", "reason":"auth error"};
}
})();
const responseBody = ResponseBody.create({
status: "000",//システムで定義したいであろうコード
message: "Successed",//Statusを言葉で端的に説明したもの
data: outbound
});
const response = Response.create({
status: Status.OK,
type: "json",
body: responseBody.getData()
});
ctx.response.status = response.getStatus();
ctx.response.type = response.getType();
ctx.response.body = response.getBody();
return ;
}
ルーティング追加
import { Router } from "oak/mod.ts";
import memoListController from "../contexts/memo/application/controller/api/list_controller.ts";
import authorizationController from "../contexts/account/application/controller/api/authorization_controller.ts";
const router = new Router();
router.get("/memo", memoListController);
router.post("/auth/token", authorizationController);
export default router;
動作確認
$ curl -X POST -s -d "user=yamamoto" -d "password=yamapass" http://localhost/auth/token
{"status":"000","message":"Successed","data":{"user":"yamamoto","accessToken":"本当は発行する処理が必要","expired":0}}
認可ミドルウェアを作る
エンドポイントとしてではなく、各ルートに挟み込めるミドルウェアが必要になって来ます。
この項目ではミドルウェアの作り方の説明のみとなります。認可の実装自体は説明しません。
$ mkdir middleware
$ touch ./middleware/authentication.ts
import { isHttpError, Status } from "oak/mod.ts";
export async function authMiddleware (ctx: any, next: any) {
try {
//手抜きで
const headers = ctx.request.headers;
const token = await headers.get('Token');
if (token === "OKToken") {
await next();
} else {
ctx.throw(Status.Unauthorized, "Authentication failed!");
}
} catch (err) {
if (isHttpError(err)) {
ctx.response.status = err.status;
ctx.response.type = "json";
ctx.response.body = {
status: err.status >= 400 && err.status < 500 ? "fail" : "error",
message: err.message,
};
}
}
}
ルーティングにミドルウェアを追加する
import { Router } from "oak/mod.ts";
import { authMiddleware } from "../middleware/authentication.ts";
import memoListController from "../contexts/memo/application/controller/api/list_controller.ts";
import authorizationController from "../contexts/account/application/controller/api/authorization_controller.ts";
const router = new Router();
//router.get("/memo", memoListController);
router.post("/auth/token", authorizationController);
const authedRouter = new Router()
authedRouter.use(authMiddleware);
authedRouter.get("/memo", memoListController);
router.use(authedRouter.routes());
export default router;
/memo
へ先ほど作ったミドルウェアを差し込みました。
手抜きミドルウェアなので、Token
ヘッダーがOKToken
の場合に認可を通ったものとして扱っています。
動作確認
$ curl -H "Token: OKToken" -s http://localhost/memo?user_id=10
{"status":"200","message":"Successed","data":[{"id":28,"userId":10,"content":"memo.content"}]}
$ curl -H "Token: NGToken" -s http://localhost/memo?user_id=10
{"status":"fail","message":"Authentication failed!"}
以上で本記事は終わりですが、これでロクでもない状態のメモ取得APIを改善する事が出来るのでぜひ修正までしてみてください。
tree
最終的にこのようなツリーになりました。(実装出来なかったファイルも含む).
├── common
│ └── response.ts
├── contexts
│ ├── account
│ │ ├── application
│ │ │ ├── controller
│ │ │ │ ├── api
│ │ │ │ │ ├── authentication_controller.ts
│ │ │ │ │ ├── authorization_controller.ts
│ │ │ │ │ ├── delete_controller.ts
│ │ │ │ │ └── store_controller.ts
│ │ │ │ └── web
│ │ │ │ ├── authentication_controller.ts
│ │ │ │ ├── authorization_controller.ts
│ │ │ │ ├── delete_controller.ts
│ │ │ │ └── store_controller.ts
│ │ │ └── use_case
│ │ │ ├── authentication
│ │ │ ├── authorization
│ │ │ │ ├── input.ts
│ │ │ │ ├── interactor.ts
│ │ │ │ └── output.ts
│ │ │ ├── delete
│ │ │ └── store
│ │ ├── domain
│ │ │ ├── entity
│ │ │ │ ├── account.ts
│ │ │ │ └── authentication.ts
│ │ │ ├── exception
│ │ │ └── persistence
│ │ │ └── account_repository.ts
│ │ └── infrastructure
│ │ ├── persistence
│ │ │ └── account_repository_impl_sqlite.ts
│ │ └── presenter
│ └── memo
│ ├── application
│ │ ├── controller
│ │ │ ├── api
│ │ │ │ ├── delete_controller.ts
│ │ │ │ ├── list_controller.ts
│ │ │ │ ├── store_controller.ts
│ │ │ │ └── update_controller.ts
│ │ │ └── web
│ │ │ ├── delete_controller.ts
│ │ │ ├── list_controller.ts
│ │ │ ├── store_controller.ts
│ │ │ └── update_controller.ts
│ │ └── use_case
│ │ ├── delete
│ │ ├── list
│ │ │ ├── input.ts
│ │ │ ├── interactor.ts
│ │ │ └── output.ts
│ │ ├── store
│ │ │ ├── input.ts
│ │ │ ├── interactor.ts
│ │ │ └── output.ts
│ │ └── update
│ ├── domain
│ │ ├── entity
│ │ │ └── memo.ts
│ │ ├── exception
│ │ └── persistence
│ │ └── memo_repository.ts
│ └── infrastructure
│ ├── persistence
│ │ └── memo_repository_impl_sqlite.ts
│ └── presenter
│ ├── html.ts
│ └── json.ts
├── db
│ ├── migrations
│ │ ├── 20221127111118_create_memos.ts
│ │ └── 20221130235847_create_accounts.ts
│ └── seeds
├── import_map.json
├── log.txt
├── main.ts
├── middleware
│ └── authentication.ts
├── nessie.config.ts
├── routes
│ ├── api.ts
│ └── web.ts
└── sqlite.db
あとがき
土日祝と平日夜中ほぼ全部潰して書いて何とか一通りの実装手順を書き切る事が出来ました。
実は去年もoakを試しており、その時もクリーンアーキテクチャーを意識して認証周りを作ったので大体のdeno-oak-mysql
での実装手順は分かっていました。しかし今回はDBをsqlite
に変えて最新バージョンのdenoとoakで作りつつUshio Architecture
やその他クリーンアーキテクチャー関係のサイトを参考にしながらマイグレーションを採用したり多少テストを書いたりと色々試みて改めて作り直した為に時間が掛かりました。
つきましては開発部長殿、次回技術記事を書く機会を頂けるのであればその為の時間の確保を宜しくお願い奉り候。
トラストバンクでは一緒に活躍いただけるエンジニアを募集中です。