Nimでは低レイヤーのカーネルの開発からアプリケーションの開発まで幅広くできますが、特にアプリケーションの開発では保守性と変更容易性を高くするための設計が重要です。
言語の仕様によって採用できるデザインパターン、できないデザインパターンがありますが、ここではNimでアプリケーション開発をするために取り入れるべきデザインパターンと、その実装方法について説明します。
今回作るものは
- WebのAPIサーバーとCLIから呼び出される
- 機能はユーザーデータの保存と全件呼び出し
- ユーザーデータは
uid
、苗字
、名前
、苗字(カナ)
、名前(カナ)
、メールアドレス
、生年月日
、年齢
を持つ - データベースは
Sqlite
とJSONファイル
の2通りがある
を題材にして説明していきます。
オニオンアーキテクチャ
オニオンアーキテクチャは、アプリケーションの仕様をレイヤー(層)に分けて責務を切り分け、責務毎のクラスで表現するオブジェクト指向のデザインパターンです。
オニオンアーキテクチャは以下のように層を切り分けます
- プレゼンテーション層
- アプリケーション層
- ドメイン層
- インフラ層
プレゼンテーション層はwebとのリクエスト・レスポンスをやり取りしたり、CLIから呼ばれるエントリーポイントになるファイルです。
アプリケーション層はドメイン層のクラスや関数を呼び出して、ユーザーのやりたいことを実現します。
ドメイン層はアプリケーションの仕様やルールが定義されています。
インフラ層はデータベースとの接続や、外部APIとの接続、データのやり取りを行います。
更にドメイン層は以下のように分割されます。
- 値オブジェクト(value object)
- エンティティ(entity)
- ドメインサービス
値オブジェクトは単一の値についての値とふるまいが定義されています。
エンティティはフィールドとして複数の値オブジェクトを持ち、値とふるまいが定義されています。
ドメインサービスは複数のエンティティを比較したり、エンティティが持つ値を使った計算や処理が定義されています。
Nimにはクラスはありませんが、構造体(type)を使うことで実装できます。
Nimでのオブジェクト指向
アプリケーションを作るデザインパターンはオブジェクト指向の仕組みを使って実装されます。例としてユーザーオブジェクトを実装してみましょう。
それぞれの値はその値が正しく存在するためのルールを持っています。
苗字
、名前
であれば1文字以上の文字列である必要がありますし、苗字(カナ)
、名前(カナ)
はカタカナで、メールアドレス
はEmailのフォーマットでなければいけないし、生年月日
はyyyy-MM-dd
のフォーマットです。
こうした値のチェックをコンストラクタで行い、不正な値が決して存在しないようにするコンストラクタを完全コンストラクタと呼びます。
ドメインモデルである値オブジェクトとエンティティは完全コンストラクタで作る必要があります。
またコンストラクタ以外で値を直接渡されないように、フィールドはプライベートにして、取り出す時はgetter
の関数を経由するようにしましょう。
値オブジェクト
domain/user/user_value_objects.nim
import
std/nre,
std/options,
std/times,
std/oids,
std/sha1,
../../errors
type Uid* = ref object
value:string
proc new*(_:type Uid):Uid =
return Uid(
value: genOid().`$`.secureHash().`$`
)
proc `$`*(self:Uid):string =
return self.value
type Name* = ref object
value:string
proc new*(_:type Name, value:string):Name =
if value.len == 0:
raise newException(DomainError, "名前が空です")
return Name(value:value)
proc `$`*(self:Name):string =
return self.value
type KanaName* = ref object
value:string
proc new*(_:type KanaName, value:string):KanaName =
if value.len == 0:
raise newException(DomainError, "カナ名前が空です")
if not value.match(re"(*UTF8)^[ァ-ン]+").isSome:
raise newException(DomainError, "カナ名前がカタカナでありません")
return KanaName(value:value)
proc `$`*(self:KanaName):string =
return self.value
type Email* = ref object
value:string
proc new*(_:type Email, value:string):Email =
if value.len == 0:
raise newException(DomainError, "メールアドレスが空です")
if not value.match(re".+@.+").isSome:
raise newException(DomainError, "メールアドレスが正しくありません")
return Email(value:value)
proc `$`*(self:Email):string =
return self.value
type BirthDate* = ref object
value: DateTime
proc new*(_:type BirthDate, value:string):BirthDate =
if not value.match(re"^[0-9]{4}\-[0-9]{2}\-[0-9]{2}$").isSome:
raise newException(DomainError, "生年月日は1990-01-10の形式です")
return BirthDate(value:value.parse("yyyy-MM-dd"))
proc `$`*(self:BirthDate):string =
return self.value.format("yyyy-MM-dd")
proc get*(self:BirthDate):DateTime =
return self.value
type YearsOld* = ref object
value:int
proc new*(_:type YearsOld, value:int):YearsOld =
if value < 0:
raise newException(DomainError, "年齢は0以上です")
return YearsOld(
value: value
)
proc get*(self:YearsOld):int =
return self.value
import
std/nre,
std/options,
std/times,
std/oids,
std/sha1,
../../errors
import文は
- 標準ライブラリは
std/
を付けて呼び出す - 上から順番に標準ライブラリ、nimbleでインストールした3rdパーティーライブラリ、同一アプリケーション内の他のファイルになるようにする
- ライブラリの追加削除で行単位の差分が発生しないように横に書かず縦に並べる
にしましょう。
type Email* = ref object
value:string
proc new*(_:type Email, value:string):Email =
if value.len == 0:
raise newException(DomainError, "メールアドレスが空です")
if not value.match(re".+@.+").isSome:
raise newException(DomainError, "メールアドレスが正しくありません")
return Email(value:value)
proc `$`*(self:Email):string =
return self.value
オブジェクトの宣言にはref object
を使います。
Nimでは関数の引数に入れられた変数の容量に応じてコンパイラが自動で値渡し/参照渡しを調節しますが、これは挙動の予測が付かずバグの原因になりえます。ref object
でオブジェクトを宣言していれば必ず参照渡しになるので、アプリケーション開発ではこちらに統一しましょう。
object
はCの構造体を呼ぶなど低レイヤーでの開発に必要になります。
Status社のスタイルガイドではinit
とnew
の関数をコンストラクタとして使うように書いてあります。
型Hoge
のコンストラクタはNimでは一般的にnewHoge()
関数を使いますが、第一引数をtype Hoge
にすることで、Hoge.new(arg)
の形で呼び出すことができます。
Githubでは現在でも議論が続いています。
[RFC] Another proposal for a standardized object construction/initialization.
文字列を返すgetterの関数は、他のライブラリに合わせて「$」にしましょう。
エンティティ
では値オブジェクトをフィールドに持つ、Userオブジェクト
を実装していきます。
エンティティとはテーブルの行データのようなものです。しかしRDBのテーブルと完全に一致するものではありません。例えば今回のUserオブジェクト
であれば、年齢
というフィールドがありますが、これは生年月日
フィールドから計算されて作られるフィールドです。このような値はコンストラクタで生成されるようにします。
エンティティも値オブジェクトと同じように、完全コンストラクタで値の整合性を保証し、フィールドはプライベートにし、不正な値が存在しないようにします。
domain/user/user_entity.nim
import
std/times,
user_value_objects
type User* = ref object
uid:Uid
lastName:Name
firstName:Name
kanaLastName:KanaName
kanaFirstName:KanaName
email:Email
birthDate:BirthDate
yearsOld:YearsOld
proc new*(_:type User,
uid:Uid,
lastName: Name,
firstName: Name,
kanaLastName: KanaName,
kanaFirstName: KanaName,
email: Email,
birthDate: BirthDate
):User =
return User(
uid: uid,
lastName: lastName,
firstName: firstName,
kanaLastName: kanaLastName,
kanaFirstName: kanaFirstName,
email: email,
birthDate: birthDate,
yearsOld: YearsOld.new(
now().year - birthDate.get.year
)
)
proc uid*(self:User):Uid =
return self.uid
proc lastName*(self:User):Name =
return self.lastName
proc firstName*(self:User):Name =
return self.firstName
proc kanaLastName*(self:User):KanaName =
return self.kanaLastName
proc kanaFirstName*(self:User):KanaName =
return self.kanaFirstName
proc email*(self:User):Email =
return self.email
proc birthDate*(self:User):BirthDate =
return self.birthDate
proc yearsOld*(self:User):YearsOld =
return self.yearsOld
ポリモーフィズム
ここまで値オブジェクトとエンティティの実装を通してドメインモデルの作り方を見てきました。ドメインモデルとはデータとその振る舞いを集約し、ビジネスルールが外部から切り離して存在するようにしたものです。
ここからはデータの永続化について見ていきましょう。
データの永続化はリポジトリ
パターンを使います。リポジトリ
はエンティティを永続化するものです。
データの保存先がRDB
なのかJSONファイル
なのかCSV
なのか外部API
なのかということはドメインの関心事ではありません。ドメインは知っていてはいけないことです。
今回はUser
オブジェクトの保存とデータベースからの取り出しという2つの仕様が存在すると考えてみましょう。保存先がRDB
なのかJSONファイル
なのかで保存と取り出しの具体的な実装は大きく変わりますが、存在する関数が呼び出される時の見え方は同じです。すなわちUser
オブジェクトを引数に持つsave
関数によって保存し、getAll
関数によってデータを取り出すということです。
インターフェース
を定義し、リポジトリの具象クラスがインターフェースに沿った仕様になることを保証しましょう。
domain/user/user_repository_interface.nim
import
std/asyncdispatch,
std/json,
user_entity
type IUserRepository* = tuple
getAll:proc():Future[JsonNode]
save:proc(user:User):Future[void]
libs/db.nim
import
std/os,
std/db_sqlite
let conn* = open(getCurrentDir() / "data.sqlite", "", "", "")
infra/user/rdb_user_repository.nim
import
std/json,
std/db_sqlite,
std/asyncdispatch,
interface_implements,
../../libs/db,
../../domain/user/user_value_objects,
../../domain/user/user_entity,
../../domain/user/user_repository_interface
type RdbUserRepository* = ref object
db:DbConn
proc new*(_:type RdbUserRepository):RdbUserRepository =
return RdbUserRepository(
db: conn
)
implements RdbUserRepository, IUserRepository:
proc getAll(self:RdbUserRepository):Future[JsonNode] {.async.} =
let query = sql"select * from users"
let rows = self.db.getAllRows(query)
let res = newJArray()
for row in rows:
res.add(%*{
"id": row[0],
"lastName": row[1],
"firstName": row[2],
"kanaLastName": row[3],
"kanaFirstName": row[4],
"email": row[5],
"birthDate": row[6],
})
return res
proc save(self:RdbUserRepository, user:User):Future[void] {.async.} =
let query = sql"""insert into users (
id, last_name, first_name,
kana_last_name, kana_first_name,
email, birth_date
) values (?, ?, ?, ?, ?, ?, ?)"""
self.db.exec(query, [
$user.uid, $user.lastName, $user.firstName,
$user.kanaLastName, $user.kanaFirstName,
$user.email, $user.birthDate
])
infra/user/json_user_repository.nim
import
std/os,
std/json,
std/asyncdispatch,
interface_implements,
../../domain/user/user_value_objects,
../../domain/user/user_entity,
../../domain/user/user_repository_interface
type JsonUserRepository* = ref object
path:string
proc new*(_:type JsonUserRepository):JsonUserRepository =
return JsonUserRepository(
path: getCurrentDir() / "data.json"
)
implements JsonUserRepository, IUserRepository:
proc getAll(self:JsonUserRepository):Future[JsonNode] {.async.} =
var f = open(self.path, fmRead)
defer: f.close()
let stream = f.readAll()
if stream.len == 0:
return newJArray()
let jsonContent = stream.parseJson()
if not jsonContent.hasKey("users"):
return newJArray()
return jsonContent["users"]
proc save(self:JsonUserRepository, user:User):Future[void] {.async.} =
var f = open(self.path, fmRead)
defer: f.close()
let stream = f.readAll()
var jsonContent =
if stream.len == 0:
let j = newJObject()
j["users"] = newJArray()
j
else:
stream.parseJson()
if not jsonContent.hasKey("users"):
jsonContent["users"] = newJArray()
jsonContent["users"].add(%*{
"id": $user.uid,
"lastName": $user.lastName,
"firstName": $user.firstName,
"kanaLastName": $user.kanaLastName,
"kanaFirstName": $user.kanaFirstName,
"email": $user.email,
"birthDate": $user.birthDate,
})
self.path.writeFile($jsonContent)
di_container.nim
import
domain/user/user_repository_interface,
infra/user/rdb_user_repository,
infra/user/json_user_repository
type DiContainer* = tuple
userRepository: IUserRepository
proc new*(_:type DiContainer):DiContainer =
return (
userRepository: RdbUserRepository.new().toInterface(),
# userRepository: JsonUserRepository.new().toInterface(),
)
let di* = DiContainer.new()
ここでは私が作ったライブラリのinterface-implements
を使っています。このライブラリのimplementsマクロ
を使うことで、具象クラスがインターフェースに定義されているメソッドの実装漏れや引数や返り値の不一致があるとコンパイルエラーが起きるようになります。
アプリケーション層からドメイン層を呼び出す
アプリケーション層とはドメインモデルを呼び出し、ユーザーのやりたいことを実現する層です。User
オブジェクトを保存するというユースケースを実装していきましょう。
application/crate_new_user_usecase.nim
import
std/asyncdispatch,
../domain/user/user_value_objects,
../domain/user/user_entity,
../domain/user/user_repository_interface,
../di_container
type CreateNewUserUsecase* = ref object
repository:IUserRepository
proc new*(_:type CreateNewUserUsecase):CreateNewUserUsecase =
return CreateNewUserUsecase(
repository: di.userRepository
)
proc invoke*(
self:CreateNewUserUsecase,
lastName:string,
firstName:string,
kanaLastName:string,
kanaFirstName:string,
email:string,
birthDate:string
){.async.} =
let uid = Uid.new()
let lastName = Name.new(lastName)
let firstName = Name.new(firstName)
let kanaLastName = KanaName.new(kanaLastName)
let kanaFirstName = KanaName.new(kanaFirstName)
let email = Email.new(email)
let birthDate = BirthDate.new(birthDate)
let user = User.new(
uid, lastName, firstName, kanaLastName, kanaFirstName,
email, birthDate
)
await self.repository.save(user)
次にこちらはユーザーデータ全件を取得するユースケースです
import
std/asyncdispatch,
std/json,
../domain/user/user_repository_interface,
../di_container
type GetAllUsersUsecase* = ref object
repository: IUserRepository
proc new*(_:type GetAllUsersUsecase):GetAllUsersUsecase =
return GetAllUsersUsecase(
repository: di.userRepository
)
proc invoke*(self:GetAllUsersUsecase):Future[JsonNode] {.async.} =
let users = await self.repository.getAll()
return users
ユースケースが持つフィールドのrepository
はIUserRepository
という抽象に依存しています。repository
の実体である具象クラスが何かは、di_container
で管理されています。このような呼び出すメソッドの具象クラスを外部から受け渡すことを依存性の注入
と言います。これを使ってリポジトリ
を外部から注入することをDDDなどの文脈では依存性の逆転
と言います。
プレゼンテーション層からアプリケーション層を呼び出す
WebやCLIと言ったアプリケーションの入出力固有の責務を担当する層をプレゼンテーション層
と言います。Webであればhttp通信のリクエスト・レスポンスという固有のプロトコルに則った入出力になりますし、CLIであれば標準入出力というプロトコルになります。そういった入出力のやり取りを担当し、アプリケーション層にstring型やint型と言ったプリミティブな値でやり取りするのが責務です。
外部とアプリケーションのエントリーポイントであるアプリケーション層を切り分けることで、ビジネスルールの変更がIOに影響されることなく、Webからの呼び出し口とCLIからの呼び出し口を共通化することができます。
Webから呼び出す
Webは標準ライブラリのasynchttpserver
を使ってAPIサーバーを実装していきます。
presentation/web.nim
import
std/asynchttpserver,
std/asyncdispatch,
std/json,
../errors,
../application/create_new_user_usecase,
../application/get_all_users_usecase
let htmlResponseHeader = {"Content-type": "text/html; charset=utf-8"}.newHttpHeaders()
let jsonResponseHeader = {"Content-type": "application/json; charset=utf-8"}.newHttpHeaders()
proc main {.async.} =
var server = newAsyncHttpServer()
proc cb(req: Request) {.async, gcsafe.} =
if req.reqMethod == HttpGet and req.url.path == "/":
await req.respond(Http200, "Hello World", htmlResponseHeader)
elif req.reqMethod == HttpGet and req.url.path == "/users":
try:
let usecase = GetAllUsersUsecase.new()
let resp = %*{
"users": await usecase.invoke()
}
await req.respond(Http200, $resp, jsonResponseHeader)
except DomainError:
let resp = %*{
"error": getCurrentExceptionMsg()
}
await req.respond(Http400, $resp, jsonResponseHeader)
except InfraError:
let resp = %*{
"error": getCurrentExceptionMsg()
}
await req.respond(Http500, $resp, jsonResponseHeader)
elif req.reqMethod == HttpPost and req.url.path == "/users":
let params = req.body.parseJson()
let lastName = params["lastName"].getStr
let firstName = params["firstName"].getStr
let kanaLastName = params["kanaLastName"].getStr
let kanaFirstName = params["kanaFirstName"].getStr
let email = params["email"].getStr
let birthDate = params["birthDate"].getStr
let usecase = CreateNewUserUsecase.new()
try:
await usecase.invoke(lastName, firstName, kanaLastName, kanaFirstName, email, birthDate)
let resp = %*{
"msg": "success"
}
await req.respond(Http200, $resp, jsonResponseHeader)
except DomainError:
let resp = %*{
"error": getCurrentExceptionMsg()
}
await req.respond(Http400, $resp, jsonResponseHeader)
except InfraError:
let resp = %*{
"error": getCurrentExceptionMsg()
}
await req.respond(Http500, $resp, jsonResponseHeader)
else:
await req.respond(Http404, "", htmlResponseHeader)
server.listen(Port(8000))
while true:
if server.shouldAcceptRequest():
await server.acceptRequest(cb)
else:
await sleepAsync(500)
waitFor main()
実行します
cd /app/src
nim c -r presentation/web
CLIから呼び出す
同じようにCLIから呼び出します。ここではcligen
という3rdパーティーライブラリを使います。
NimのCLIツール作成用ライブラリcligenがとても便利
import
std/asyncdispatch,
std/json,
../application/create_new_user_usecase,
../application/get_all_users_usecase
proc createUser(args: seq[string]) {.async.} =
var lastName = ""
var firstName = ""
var kanaLastName = ""
var kanaFirstName = ""
var email = ""
var birthDate = ""
for i in 0..<6:
case i
of 0:
echo "苗字を入力して下さい"
lastName = stdin.readLine
of 1:
echo "名前を入力して下さい"
firstName = stdin.readLine
of 2:
echo "苗字(カナ)を入力して下さい"
kanaLastName = stdin.readLine
of 3:
echo "名前(カナ)を入力して下さい"
kanaFirstName = stdin.readLine
of 4:
echo "メールアドレスを入力して下さい"
email = stdin.readLine
of 5:
echo "生年月日をハイフン区切りで入力して下さい"
birthDate = stdin.readLine
else:
discard
let usecase = CreateNewUserUsecase.new()
await usecase.invoke(
lastName, firstName, kanaLastName, kanaFirstName, email, birthDate
)
proc getUsers(args:seq[string]) {.async.} =
let usecase = GetAllUsersUsecase.new()
echo await usecase.invoke()
when isMainModule:
import cligen
dispatchMulti([createUser], [getUsers])
実行します
cd /app/src
nim c -r presentation/cli createUser
nim c -r presentation/cli getUsers
おわりに
アプリケーション全体の構成としてはこうなりました。
├── application
│ ├── create_new_user_usecase.nim
│ └── get_all_users_usecase.nim
├── data.json
├── data.sqlite
├── di_container.nim
├── domain
│ └── user
│ ├── user_entity.nim
│ ├── user_repository_interface.nim
│ └── user_value_objects.nim
├── errors.nim
├── infra
│ └── user
│ ├── json_user_repository.nim
│ └── rdb_user_repository.nim
├── libs
│ └── db.nim
├── migrate.nim
└── presentation
├── cli.nim
└── web.nim
ソースコードはこちらから
https://github.com/itsumura-h/nim-oop-bestpractice
このようにNimではオニオンアーキテクチャの実装方針を採用することができ、単一責任原則
と一方向の依存性
、依存性の注入
と言ったオブジェクト指向のエッセンスを取り入れて、保守性の高いアプリケーション開発をすることができます。
Web開発にこのようなオニオンアーキテクチャの要素を取り入れたNim製WebフレームワークであるBasolato
を開発していますので、そちらもご覧頂けたらと思います。
ありがとうございました。