はじめに
この記事では、プログラム設計に焦点をあてて、よりよい設計をするために私が気を付けている点などについてまとめようとおもいます。
今回 Python のコードを取り上げますが、これは私が実際業務でおこなったコード改善活動で書いたコードをベースにしています。
コードの概要
このコードはログの解析をおこなっていまして、処理としては、
1. 対象となるログファイルを読み込む
2. 集計する
3. データベースに結果を書き込む
というシンプルな内容です。
とあるログファイルを解析し、ユーザーのログイン状況を集計する内容になっています。
今回のコード改良にあたり、まずざっくりとした設計図を書くところからはじめました。
設計をするときには、ドメインを抽出するところからはじめます。
そしてそれらドメインを呼び出すアプリケーション、あるいはインフラストラクチャに役割を分割しながら考えていきました。
そもそもなぜ改良したかったか
もともとこのコードはバッチとして稼働していましたが、その性質上アプリというよりは "処理" という側面が強く、コードも処理の羅列とよぶべきものでした。
そのため、コードが読みにくかったり、似たようなほかのバッチを追加するために再利用するのが難しかったりと、いろいろ問題がでてきたため、再設計する運びとなりました。
コードの内容
ドメイン
実装の詳細にまでは立ち入りませんが、だいたいどんな設計をしたか、ということを述べようとおもいます。
このバッチ処理はログファイルを読み込んでユーザーのログイン状況を解析するため、Login
というクラスを作りました。
また、Tenant
や Group
はユーザーが属する組織を示していまして、それらもドメインとして作りました。
以下に実際のコードをいくつか載せます。
#!/usr/bin/env python3
# -*= coding: utf-8 -*-
import re
class Login:
LOGIN_MSG = "Logged in successfully\."
LOG_FMT = r"( ip:)([^ ]+)( domain:)([^ ]+)"
LOG_FMT += r"( tid:)([^ ]+)( gid:)([^ ]+)"
LOG_FMT += r" [^ ]+\] " + LOGIN_MSG
COLUMN_TENANT_ID = 6
COLUMN_GROUP_ID = 8
def __init__(self, line, tenant_id):
self.pattern = re.compile(Login.LOG_FMT)
self.match = self.pattern.search(line)
if (int(tenant_id) != int(self.match.group(Login.COLUMN_TENANT_ID))):
return None
self.group_id = int(self.match.group(Login.COLUMN_GROUP_ID))
def get_group_id(self):
return self.group_id
def output(self):
print(self.match)
print(self.match.groups())
print(self.tenant_id)
print(self.group_id)
ドメインの中には、処理対象に関するコアな情報を記載しています。
処理対象となるログファイルの構成や、読み込みたい文字列など、処理の中核となる情報をドメインに閉じ込めるようにします。
このとき、これらドメインは、アプリケーションが呼び出すだけなく、ファイルやDBへのアクセスをおこなうリポジトリも呼び出しますから、"使われ方" を気にしながら作っていきます。
ある程度作ったら、Python インタフェースからクラスを読み込んでインスタンス化してみたりして、使い方をイメージしながら作りこんでいきます。
#!/usr/bin/env python3
# -*= coding: utf-8 -*-
class Tenant:
def __init__(self, tenant_id, groups):
self.tenant_id = tenant_id
self.groups = groups
def get_tenant_id(self):
return self.tenant_id
def get_groups(self):
return self.groups
ドメインとなるクラスを考えるときには、なにをクラスとして切り出すのか、それぞれのメンバ変数にはなにがあるのか、といったことに悩むことが多々あります。
最初に思い描いた設計の通りに進まないことが多いので、手を動かしながら着地点を模索します。
アプリケーション
アプリケーションでは、ドメインを呼び出し、処理の順番を制御します。
今回は **Service
という名前のアプリケーションクラスを3つ作成し、それらを統合するクラスとして Controller
をつくっています。
3つのアプリケーションクラスの役割は以下の通りです。
-
ConfigService
: 設定ファイルを読み込んで、処理対象となるテナントを取得する -
LogService
: メインとなるログファイルの分析をおこなう -
ResultService
: 処理結果をデータベースに格納する
#!/usr/bin/env python3
# -*= coding: utf-8 -*-
import sys
sys.path.append('../')
from domain.tenant import Tenant
from repository.config_repository import ConfigRepository
class ConfigService:
def handle(self):
repo = ConfigRepository()
tenants = repo.list()
return tenants
設定ファイルを読み込む処理はリポジトリでおこなっているので、ここではリポジトリを呼び出すことでデータを取得しています。
#!/usr/bin/env python3
# -*= coding: utf-8 -*-
import sys
sys.path.append('../')
from domain.tenant import Tenant
from domain.group import Group
from repository.log_repository import LogRepository
class LogService:
def handle(self, _date, tenants):
count = 0
repo = LogRepository(_date)
for tenant in tenants:
logins = repo.findByTenantId(tenant.get_tenant_id())
for group in tenant.get_groups():
count = logins.count(group.get_group_id())
group.set_count(count)
return tenants
LogService
も、リポジトリを呼び出すことでログファイルを読み込む処理をおこなったり、ドメインに定義されたメソッドを呼び出したりして処理を実行します。
アプリケーションが担う責務は、ドメインやリポジトリで定義されたメソッドを正しい順番で呼び出して、全体を統括することを中心としたいので、アプリケーションにはできるだけ業務知識を書かないようにしています。
アプリケーションがデータの取得をリポジトリに依頼する際には、その結果がドメインとして返されるようにしています。
その後、アプリケーションでは受け取ったドメインを使って処理を行い、処理結果を格納する際にはまたリポジトリにそれを依頼します。
#!/usr/bin/env python3
# -*= coding: utf-8 -*-
import sys
sys.path.append('../')
from domain.tenant import Tenant
from application.log_service import LogService
from application.config_service import ConfigService
from application.result_service import ResultService
class Controller:
def exec(self, _date):
c_srv = ConfigService()
tenants = c_srv.handle()
l_srv = LogService()
results = l_srv.handle(_date, tenants)
r_srv = ResultService()
r_srv.handle(results)
return True
リポジトリ
リポジトリでは、ファイルからデータを読み込んだり、データベースを読み書きしたりすることを役目とします。
3つのクラスを作りまして、それぞれの役割は以下の通りです。
-
ConfigRepository
: 今回の処理に利用する設定ファイルの読み込みをおこなう -
LogRepository
: ログファイルを読み込む -
ResultRepository
: 処理結果をデータベースに書き込む
import sys
from pathlib import Path
import configparser
sys.path.append('../')
from domain.login import Login
from domain.logins import Logins
class LogRepository:
def __init__(self, _date):
self._date = _date
self.root = Path().cwd().parent.parent
self.log_path = self.root / 'storage' / 'logs'
self.p = list(self.log_path.glob('*' + self._date + '*'))[0]
def findByTenantId(self, tenant_id):
logins = Logins(self._date, tenant_id)
with self.p.open('r', encoding='utf-8') as f:
for line in f:
if re.search(Login.LOGIN_MSG, line):
login = Login(line, tenant_id)
if (login.uid is None): continue
logins.add_login(login)
return logins
リポジトリでは、ファイルやデータベースの読み書きを行うので、書き込む場合にはアプリケーションから渡されたドメインを書き込み、読み込む場合には指定されたデータを読み込んで、ドメインとしてアプリケーションに渡します。
上記の例では読み込みを行っているので、指定されたデータをファイルの中から検索し、見つかったデータをドメインとしてパッケージしてからアプリケーションに返してあげます。
改良してみて
今回のコード改良では、ドメインとしてなにを切り出すかということが一番悩みましたが、改善後のほうが明らかに保守性が上昇しました。
DDDを取り入れて設計していくにあたって、精緻な全体像をつくってから開発にとりかかるというよりかは、設計して開発して、という小さいサイクルを繰り返すほうが作りやすいと感じました。
1. コアとなるドメインだけをざっくり作る
2. 1を呼び出して利用するアプリケーションやリポジトリのコアな部分を作る
3. ドメインに肉付けして拡張していく
:
といった感じで私はつくっていきました。
つくっていくうちに、「あ、これはドメインクラスとして作ったほうがいいな」とか、「これはメンバ変数にしておけばいいな」といったことが見えてくるので、その都度設計図に手をいれながら、少しずつ拡張していくやり方が相性が良いとおもいました。
今回の記事は以上です。