LoginSignup
30
16

More than 1 year has passed since last update.

Nimでアプリケーション開発をするための設計のベストプラクティス

Last updated at Posted at 2021-12-04

Nimでは低レイヤーのカーネルの開発からアプリケーションの開発まで幅広くできますが、特にアプリケーションの開発では保守性と変更容易性を高くするための設計が重要です。
言語の仕様によって採用できるデザインパターン、できないデザインパターンがありますが、ここではNimでアプリケーション開発をするために取り入れるべきデザインパターンと、その実装方法について説明します。

今回作るものは

  • WebのAPIサーバーとCLIから呼び出される
  • 機能はユーザーデータの保存と全件呼び出し
  • ユーザーデータはuid苗字名前苗字(カナ)名前(カナ)メールアドレス生年月日年齢を持つ
  • データベースはSqliteJSONファイルの2通りがある

を題材にして説明していきます。

オニオンアーキテクチャ

20171011062558.png

オニオンアーキテクチャは、アプリケーションの仕様をレイヤー(層)に分けて責務を切り分け、責務毎のクラスで表現するオブジェクト指向のデザインパターンです。

オニオンアーキテクチャは以下のように層を切り分けます

  • プレゼンテーション層
  • アプリケーション層
  • ドメイン層
  • インフラ層

プレゼンテーション層は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社のスタイルガイドではinitnewの関数をコンストラクタとして使うように書いてあります。

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マクロを使うことで、具象クラスがインターフェースに定義されているメソッドの実装漏れや引数や返り値の不一致があるとコンパイルエラーが起きるようになります。

nim-interface-implements

NimでインターフェースとDIコンテナを使う

アプリケーション層からドメイン層を呼び出す

アプリケーション層とはドメインモデルを呼び出し、ユーザーのやりたいことを実現する層です。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

ユースケースが持つフィールドのrepositoryIUserRepositoryという抽象に依存しています。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を開発していますので、そちらもご覧頂けたらと思います。

ありがとうございました。

Basolatoフレームワーク

30
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
16