はじめに
Swiftでios開発をするときのアーキテクチャを考えてみました。
すでにいくつか良い記事があり、それらを参考に作るのもいいのですが、Web開発で慣れ親しんだDDDではどうなるのかと思い試しました。
結論からするとios開発では、DDDをそのままやるのではなく、あくまでDDDっぽくやるのが生産性が高そうです。
とても参考になるこちらの記事でもios開発に合ったMVVM風として実装しているし、私自身、原理主義的にやるよりもそのプロジェクトに合うように柔軟にやるのが好きです。
この記事では、以下のライブラリを使って、なるべく本質的な実装に専念できるようなコードにしたいと思います。
タイトルの「モダンな」は単にライブラリがモダンということです。
DDDに興味がなくてもこれらのライブラリの利用方法で多少なりとも参考になるかもしれません。
- Bond
- Alamofire
- ObjectMapper
- AlamofireObjectMapper
- SwiftTask
- RealmSwift
また、単体テストの書きやすさも非常に大事なので、そこも気にかけます。
能書きが嫌いな人はコードの章を見て、わからなかったら他の部分も読めばいいと思います。
DDDっぽくないところ
- ドメイン層も特定のライブラリに依存
そういう意味で、全然クリーンアーキテクチャじゃないです。
ドメインが、Bondにもろに依存しています。
なので、ドメインがViewModelっぽくなっています。
Bondを使うと大抵そうなりますが、開発効率が良いのでこうしています。
- ドメイン駆動と言いながら画面ありき
Bondがドメイン層にあるからです。
ただ、UI層やインフラ層をまるっと変えてもそんなにドメイン層を変更する必要はないかなと思いますし、ドメイン層から開発することも可能
だと思います。
アーキテクチャ
エリックエヴァンスのDDD本では、ほぼ以下の図です。
DDDという以前に、レイヤーアーキテクチャでは依存の方向性は一方向に作ります。
自分もJavaでWeb開発の時は、レイヤーごとにマルチプロジェクトにすることで、コンパイルエラーにして、逆向きの依存が絶対に起きないようにしていました。
(一つの指針として上位層から順に層を削除していってもコンパイルエラーが起きない状態であれば、レイヤーアーキテクチャと言えると思います。)
で、これを一歩進めて、ドメイン層はインフラ層に依存させたくないので、ドメイン層にprotocolを置いて、DIで実装クラスを注入するのが一般的じゃないでしょうか。
するとヘキサゴナルアーキテクチャに発展していくのだろうと思います。
(エヴァンスのDDDもつまるところ同じことを言ってると思います。)
上記の図の矢印は依存性の方向です。図にはないですが、インフラ層への方向に継承の矢印が引かれます。
これを実現するためにServiceLocator経由でRepositoryを取得しようとしました。
が、現在のSwiftの仕様上、associatedtype
のあるprotocolをgenericsを利用したメソッドで取得できません。
こんな感じの話です。
func lookup<T: Storable>() -> T?
なメソッドを作って、
let repository: WeatherRepository? = RepositoryLocator.sharedInstance.lookup()
でprotocolを取得しようとしたら、以下のエラーが出ました。
Protocol 'WeatherRepository' can only be used as a generic constraint because it has Self or associated type requirements
原因は、WeatherRepositoryにassociatedtypeを使っていたためです。Genericsを利用する曖昧なprotocolは、変数の型として宣言できないのです。(なのでDIな仕組みにしてもそこが変わらなければ同様です。)
protocol WeatherRepository: AppRepository {
associatedtype T = WeatherEntity
・・・
// realmを扱うクラス
protocol AppRepository: Storable {
associatedtype T: Object
func save(entity: Self.T)
・・・
型宣言でassociatedtype(抽象型)を使えないと開発効率が落ちます。上記の場合、AppRepository(Realmの処理が記述されている)を継承していれば、それぞれのRepositoryでfindやらsaveやらの定型的なメソッドを宣言しなくて良いのです。
もっと言えば、protocolを使わなければ、クラス一つでRealmのすべてのエンティティの操作のほとんどを賄えることができます。
なので、レイヤーの依存を正しく保つよりも、コード記述量を減らすことの利点を考えて、ドメイン層とインフラ層を緩やかに依存してもいいと思います。
今回は、DDD(風)を名乗っているので、associatedtypeで書くことを諦めて、愚直に書くことでレイヤーアーキテクチャを守ることにしました。
上記を踏まえて変更した図が以下になります。
また、利用するライブラリの補足として
- アプリケーション層からドメイン層のメソッド呼び出しの戻り値はSwiftTaskのオブジェクトになります。
- UI層とドメイン層はSwiftBondで双方向にバインディングします。そのためUI層からアプリケーション層へのメソッドの戻り値はありません。
以下はレイヤーが何のライブラリに依存しているかの図になります。
レイヤーの構成要素
簡単にエヴァンスのレイヤー・構成要素の説明と今回の場合の説明を記述しておきます。
インフラ層
他の3層を支える技術的な基盤となる層です。
エヴァンスによると
上位のレイヤを支える一般的な技術的機能を提供する。これにはアプリケーションのためのメッセージ送信、ドメインのための永続化、ユーザインタフェースのためのウィジェット描画などがある。インフラストラクチャ層は、ここで示す4層聞における相互作用のパターンも、アーキテクチャフレームワークを通じてサポートすることがある。
今回は、Repositoryと他の層で利用するServiceの実装(トランザクション・APIエラーハンドリングなど)を置く層になります。
Repository
ドメインなどの永続化機構を扱うクラスになります。
今回は、UserDefaultsだったり、Realmだったり、APIだったりを扱うクラスになります。
Service
インフラ固有のサービスロジッククラスになります。
他の層で利用する実装クラスも含まれます。
今回は、Realmのトランザクション機構だったり、Api呼び出しの共通処理だったり、Apiのエラー処理だったりを記述しています。
ドメイン層
エヴァンスによると
ビジネスの概念と、ビジネスが置かれた状況に関する情報、およびビジネスルールを表す責務を負う。ビジネスの状況を反映する状態はここで制御され使用されるが、それを格納するという技術的な詳細は、インフラストラクチャに委譲される。この層がビジネスソフトウェアの核心である。
Entity
識別子(アイデンティティ)があるデータで、ドメインモデルの主役になります。属性が違っていても識別子で同一かどうかを判断できるようにします。
Value Object
属性そのものが重要で、識別することに意味がないデータです。物事の性質を表すものになります。immutable(不変)である必要があります。簡単に言うと型になり得るものです。
今回の場合は、Temperature型や、enumもここに含めます。
Aggregate
常に整合性を保っている必要のあるビジネスルールの単位をオブジェクト表現したものです。集約の中に一つのルートエンティティがあり、外部参照できるのはそのエンティティのみにします。
iosの場合、ほとんどAPIからデータを取得するので、それがほぼ集約っぽいエンティティクラスになっているかと思います。
今回のアーキテクチャでは、BondのObservalオブジェクトを持ち、APIから取得したルートエンティティを保持するクラスを集約と呼ぶことにします。
Service
1つの機能や処理が単体で存在していて、モノとして扱うのが不自然なもので、メソッドがユビキタス言語で表現されるものをクラスにしたものです。基本的にステートレスになります。ドメインモデルが入出力になるような振る舞いを持ったクラスなどになります。
今回は利用していません。
何を言ってるかわからないかもしれませんが、必要であれば作ると良いでしょう。
アプリケーション層
エヴァンスによると
ソフトウェアが行うことになっている仕事を定義し、表現力豊かなドメインオブジェクトが問題を解決するように導く。このレイヤが責務を負う作業は、ビジネスにとって意味があるものか、あるいは他システムのアプリケーション層と相互作用するのに必要なものである。
このレイヤは薄く保たれる。ピジネスルールや知識を含まず、やるべき作業を調整するだけで、実際の処理は、ドメインオブジェクトによって直下のレイヤで実行される共同作業に委譲する。ビジネスの状況を反映する状態は持たないが、 ユーザやプログラムが行う作業の進捗を反映する状態を持つことはできる。
Service
ビジネスルールはなく、薄いクラスにします。ドメイン層などを呼び出して調整作業をします。トランザクション境界になります。
今回は、トランザクション境界としての役割とエラーハンドリングをする役割を持ちます。DBで永続化するときやAPIを呼び出すときは必ず利用します。
UI層
エヴァンスによると
ユーザに情報を表示して、ユーザのコマンドを解釈する責務を負う。外部アクタは人間のユーザではなく、別のコンピュータシステムのこともある。
ViewやControllerがある層です。ここは、わかりやすいので割愛します。
コード
RealmSwift
まずはDB周りの要素。
エンティティ
RealmSwiftのObjectを継承します。また変数にdynamic修飾子をつけます。配列はListにします。
import RealmSwift
class WeatherEntity: Object {
dynamic var cityCode = ""
dynamic var title = ""
var forecasts = List<ForecastEntity>()
override static func primaryKey() -> String? {
return "cityCode"
}
・・・AlamofireとObjectMapperの処理が続く
}
レポジトリ
Realmから取得したり、保存したりする役割も持ちます。
AppRepositoryはドメイン層に置いてます。
AppRepositoryImplはインフラ層に置いてます。
このクラスに汎用的なDB処理を記述しておくと良いでしょう。
protocol AppRepository: Storable {
associatedtype T: Object
func save(entity: T)
func find() -> Results<T>?
func deleteAll()
}
import RealmSwift
class AppRepositoryImpl<T : Object>: AppRepository {
func save(entity: T) {
try! RealmWrapper.sharedInstance().add(entity)
}
func find() -> Results<T>? {
return try! RealmWrapper.sharedInstance().objects(T)
}
func deleteAll() {
try! RealmWrapper.sharedInstance().deleteAll()
}
}
インフラ層のサービス
スレッドごとのrealmインスタンスを保持しておくクラスになります。
これを用意することでサービス層(トランザクション境界)とレポジトリで同じRealmオブジェクトを使えるようにしています。
インフラ層に置いてます。
import RealmSwift
class RealmWrapper {
private static var realmPerThread: Dictionary < String, Realm > = [:]
private init() { }
static func sharedInstance() throws -> Realm {
var realm = self.realmPerThread[self.threadId()]
if realm == nil {
do {
realm = try Realm()
self.realmPerThread[threadId()] = realm
} catch let error as RealmSwift.Error {
print("Realm init error: \(error)")
switch error {
case .FileAccess:
throw Exception.LackResources
case .Fail:
throw Exception.Unexpected
default:
throw Exception.ProgramError
}
} catch {
print("Realm init error: unexpected")
throw Exception.Unexpected
}
}
return realm!
}
static func destroy() {
self.realmPerThread.removeValueForKey(self.threadId())
}
private static func threadId() -> String {
let id = "\(NSThread.currentThread())"
print(id)
return id
}
}
トランザクションを担うクラスがTransactionTemplateImplになります。
これはインフラ層に置いてます。protocolはサービス層に置いてます。
protocol TransactionTemplate {
func execute(errorObservable errorObservable: Observable<Exception>, doProcess: () -> ())
}
class TransactionTemplateImpl: TransactionTemplate {
func execute(errorObservable errorObservable: Observable<Exception>, doProcess: () -> ()) {
do {
let realm = try RealmWrapper.sharedInstance()
realm.beginWrite()
doProcess()
try realm.commitWrite()
} catch Exception.LackResources {
print("初期化失敗")
errorObservable.next(.LackResources)
} catch RealmSwift.Error.FileAccess {
print("commit失敗")
errorObservable.next(.LackResources)
} catch {
print("予期せぬエラー")
errorObservable.next(.Unexpected)
}
}
}
アプリケーション層のサービス
TransactionTemplate#execute()のclosureがトランザクション境界になります。
サービスロケーターからProtocolを取得して扱っています。こうすることで、サービス層がRealmに依存することがないので、他のDBに変更してもサービス層には影響がありません。
SwiftTaskの欄で更に説明します。
func fetchWeather() {
・・・
let tx: TransactionTemplate = self.locator.lookup()
tx.execute(errorObservable: self.weather.errorState) {
self.weather.changeForecasts(entity)
}
}
・・・
集約
上記のアプリケーション層からDB処理を移譲されます。
repository経由で複数のDB呼び出しをします。
このクラスの他の部分はSwiftBondの欄で記述します。
class Weather {
・・・
func changeForecasts(entity: WeatherEntity) {
self.entity.changeForecasts(entity.forecasts)
repository.deleteAll()
repository.save(entity)
}
・・・
AlamofireとObjectMapperとSwiftTask
エンティティ
ObjectMapperのためのコンビニエンス初期化子を記述します。
また、Mappableを実装します。
mappingメソッドで、jsonのキーを指定することで値が取得でき、それをプロパティに設定出来ます。
(ArrayTransformはObjectMapperがRealmのListに対応するための自作クラスになります)
class WeatherEntity: Object {
・・・
required convenience init?(_ map: Map) {
self.init()
}
}
extension WeatherEntity: Mappable {
func mapping(map: ObjectMapper.Map) {
self.title <- map["title"]
self.forecasts <- (map["forecasts"], ArrayTransform<ForecastEntity>())
}
}
・・・SwiftBondの処理が続く
レポジトリ
API処理を記述します。WeatherRepositoryはモデル層、WeatherRepositoryImplはインフラ層に置きます。
必ずSwiftTaskを戻り値にします。
ポイントは、ApiCallerとその引数に渡しているFetchContextになります。
import SwiftTask
protocol WeatherRepository: Storable {
func fetchWeather(cityCode: String) -> Task<Progress, WeatherEntity, Int>
・・・
import SwiftTask
class WeatherRepositoryImpl: AppRepositoryImpl<WeatherEntity>, WeatherRepository {
typealias T = WeatherEntity
typealias Promise = Task<Progress, T, Int>
func fetchWeather(cityCode: String) -> Promise {
return ApiCaller.call(FetchContext(["city": cityCode]))
}
}
private final class FetchContext: GetRestable {
var parameters: [String: AnyObject]?
required init() {
}
var path: String {
return "/forecast/webservice/json/v1"
}
}
FetchContextはURLRequestConvertibleを実装したRestableを継承しています。
FetchContextのようなクラスを作り、それぞれのAPI呼び出し用にカスタマイズしていきます。
GET用の共通部分はGetRestableに記述しています。同じようにPosstRestableなども用意しておくといいでしょう。
import Alamofire
protocol Restable: URLRequestConvertible {
var baseUrl: String { get }
var path: String { get }
var parameters: [String: AnyObject]? { get set }
var method: Method { get }
var encoding: ParameterEncoding { get }
var headers: [String: String]? { get }
init()
init(_ parameters: [String: AnyObject])
}
extension Restable {
var URLRequest: NSMutableURLRequest {
let url = NSURL(string: baseUrl)!
let request = NSMutableURLRequest(URL: url.URLByAppendingPathComponent(path))
request.HTTPMethod = self.method.rawValue
request.allHTTPHeaderFields = headers
return encoding.encode(request, parameters: parameters).0
}
}
extension Restable {
init(_ parameters: [String: AnyObject]) {
self.init()
self.parameters = parameters
print(self.URLRequest.debugDescription)
}
}
extension Restable {
var headers: [String: String]? {
return nil
}
var baseUrl: String {
return "http://weather.livedoor.com"
}
}
protocol GetRestable: Restable {
}
extension GetRestable {
var method: Alamofire.Method {
return .GET
}
var encoding: Alamofire.ParameterEncoding {
return .URL
}
}
次は、実際にAPI呼び出しを行っているクラスです。
ほとんどAlamofireObjectMapperが動作していて、SwiftTaskでPromiseパターンにしているだけです。
インフラ層のサービスに置きます。
import SwiftTask
import Alamofire
import AlamofireObjectMapper
import ObjectMapper
class ApiCaller<T: Mappable> {
typealias Promise = Task<Progress, T, Int>
class func call(context: Restable) -> Promise {
let task = Promise { progress, fulfill, reject, configure in
Alamofire.request(context)
.progress { bytesWritten, totalBytesWritten, totalBytesExpectedToWrite in
progress((bytesWritten, totalBytesWritten, totalBytesExpectedToWrite) as Progress)
}.responseObject() { (response: Response<T, NSError>) in
switch response.result {
case .Success(let entity):
fulfill(entity)
case .Failure(let error):
print("error:\(error)")
reject((response.response?.statusCode)!)
}
}
}
return task
}
}
アプリケーション層のサービス
上部のRealmSwiftで見たサービスの詳細部分を説明します。
トランザクション境界の役割以外に、SwiftTaskの結果を処理する役割も持ちます。実際の処理は集約クラスに移譲します。
import SwiftTask
class WeatherService {
private let locator: ServiceLocator!
private let weather: Weather!
init(locator: ServiceLocator = ServiceLocatorImpl.sharedInstance, weather: Weather = WeatherFactory.sharedInstance) {
self.locator = locator
self.weather = weather
}
func fetchWeather() {
let task = weather.fetchWeather()
task.success { [unowned self] entity -> Void in
entity.cityCode = self.weather.cityCode.value!
let tx: TransactionTemplate = self.locator.lookup()
tx.execute(errorObservable: self.weather.errorState) {
self.weather.changeForecasts(entity)
}
}.failure { error, isCancelled in
var handler: ApiErrorHandler = self.locator.lookup()
handler.notFound = {
self.weather.clearForecasts()
}
handler.handle(error!, isCancelld: isCancelled, errorObservable: self.weather.errorState)
}
}
わざわざinit
でデフォルトを設定している理由は単体テストをやりやすくするためです。
プロダクト上でのインスタンスはWeatherService()
で行い、単体テスト時はモックオブジェクトを使ってインスタンスをします。
・・・
func test_fetchWeatherInApp() {
class MockWeather: Weather {
var wasCalled = false
override func fetchWeatherInApp() {
self.wasCalled = true
}
}
let mock = MockWeather()
let target = WeatherService(weather: mock)
target.fetchWeatherInApp()
XCTAssertTrue(mock.wasCalled);
}
・・・
またエラーハンドリングは共通になるので別クラスにします。
このクラスでエラーコードごとにドメインの状態を変化させます。
APIで必要なエラーハンドリングは全てこのクラスに書くと良いでしょう。
class ApiErrorHandlerImpl: ApiErrorHandler {
var unexpected = { }
var notFound = { }
func handle(error: Int, isCancelld: (Bool), errorObservable: Observable<Exception>) {
switch error {
case 404:
notFound()
errorObservable.next(.NotFound)
default:
unexpected()
errorObservable.next(.Unexpected)
break
}
}
}
SwiftBond
エンティティ
Bond用に拡張します。ObservableTypeというクラスを作ってその中でBondの処理を閉じ込めています。
ここはボイラーコードなので少し考え直したほうがいいかもしれません。
が、良い方法が思いつかないのでこのようにしています。
・・・
extension WeatherEntity {
class ObservableType {
let observableForecasts = ObservableArray<ForecastEntity>()
let observableTitle: Observable<String> = Observable("")
init(entity: WeatherEntity) {
self.changeForecasts(entity.forecasts)
self.observableTitle.next(entity.title)
}
func changeForecasts(forecasts: List<ForecastEntity>) {
self.observableForecasts.removeAll()
let entities = self.convertToArray(forecasts)
self.observableForecasts.extend(entities)
}
func clearForecasts() {
self.observableForecasts.removeAll()
}
func changeTitle(title: String) {
self.observableTitle.next(title)
}
private func convertToArray(list: List<ForecastEntity>) -> [ForecastEntity] {
var entities = [ForecastEntity]()
list.forEach { entity in
entities.append(entity)
}
return entities
}
}
func observableType() -> WeatherEntity.ObservableType {
return ObservableType(entity: self)
}
}
集約
通常であれば上記のWeatherEntityがルートエンティティになりますが、Bondで扱いやすいように集約クラスを別途用意しています。
このクラスが処理のメインクラスになります。
このクラスを通して他のエンティティを扱います。
また、Bondで、このクラスの状態によって画面遷移なり、様々な処理が動くようにします。
import Bond
import SwiftTask
class Weather {
typealias Promise = Task<Progress, WeatherEntity, Int>
private(set) var repository: WeatherRepository!
private(set) var entity: WeatherEntity.ObservableType!
private(set) var dataSource: ObservableArray<ObservableArray<ForecastEntity>>!
private(set) var cityCode: Observable<String?>!
private(set) var transitionState: Observable<TransitionState>!
private(set) var errorState: Observable<Exception>!
//private init() { }
func toThird() {
if errorState.value == .None {
self.transitionState.next(TransitionState.Start)
} else {
self.errorState.next(.NotFound)
}
}
func fetchWeather() -> Promise {
return repository.fetchWeather(cityCode.value!)
}
func changeForecasts(entity: WeatherEntity) {
self.entity.changeForecasts(entity.forecasts)
repository.deleteAll()
repository.save(entity)
}
func clearForecasts() {
self.entity.clearForecasts()
}
func fetchWeatherInApp() {
if let entity: WeatherEntity = repository.find()!.last {
self.entity.changeTitle(entity.title)
self.entity.changeForecasts(entity.forecasts)
}
}
}
また、集約クラスはファクトリクラスで生成しています。
最初は private init()
にしようと思いましたが、そうすると単体テスト時にWeatherを継承させたモックを作れないので、privateは止めました。
destoroy()
メソッドを呼び出すまで同じインスタンスが返るようにしています。
class WeatherFactory {
private static var weather: Weather?
static var sharedInstance: Weather {
if let cache = self.weather {
return cache
}
self.weather = self.instance
self.weather!.repository = self.repository
self.weather!.entity = WeatherEntity().observableType()
self.weather!.cityCode = Observable<String?>("130010")
self.weather!.transitionState = Observable<TransitionState>(.None)
self.weather!.errorState = Observable<Exception>(.None)
self.weather!.dataSource = ObservableArray<ObservableArray<ForecastEntity>>([self.weather!.entity.observableForecasts])
return self.weather!
}
class var repository: WeatherRepository! {
let locator = ServiceLocatorImpl.sharedInstance
let repository: WeatherRepository = locator.lookup()
return ImplicitlyUnwrappedOptional(repository)
}
class var instance: Weather {
return Weather()
}
static func destroy() {
self.weather = nil
}
}
一部、staticメソッドではなくclassメソッドにしている理由は単体テストでオーバーロードすることでモックを返せるようにするためです。
・・・
func test_fetchWeather_failure() {
class MockFactory: WeatherFactory {
override class var instance: Weather {
return MockWeather()
}
}
let locator = MockLocator.sharedInstance
let target = WeatherService(locator: locator, weather: MockFactory.sharedInstance)
target.fetchWeather()
・・・
コントローラー
Bondで画面要素とドメインのプロパティを接続しておきます。
ユーザが画面をいじるとBondを通じてドメインの値が変わり、また、ドメインのプロパティを監視することで画面遷移やエラー表示がされるようにします。
・・・
@IBOutlet weak var cityCode: UITextField!
override func viewDidLoad() {
super.viewDidLoad()
WeatherFactory.sharedInstance.cityCode.bidirectionalBindTo(cityCode.bnd_text)
WeatherFactory.sharedInstance.transitionState.observeNew { state in
switch state {
case .Start:
self.performSegueWithIdentifier("toThird", sender: self)
break
default:
break
}
}
WeatherFactory.sharedInstance.errorState.observeNew { state in
switch state {
case .NotFound:
let alertController = UIAlertController(title: "検索結果がありません", message: "", preferredStyle: .Alert)
let okAction = UIAlertAction(title: "OK", style: .Cancel) { alert in
print(alert)
}
alertController.addAction(okAction)
self.presentViewController(alertController, animated: true, completion: nil)
break
default:
break
}
}
}
その他
ValueObject
ValueObjectはimmutable(不変)にするのでstructにするのが普通ですが、以下のTemperatureに関しては、Realmで保存したいためにclassで作成しています。なので、ほとんどEntityと同じです。
Temperatureクラス自体はプロパティを変更できない(常にインスタンス化される)ので不変ですが、Bondを保持するTemperature.ObservableTypeクラスは変化します。そういう意味で本来のValueObjectとは違うものになります。
class Temperature: Object {
private(set) var celsius = ""
private(set) var fahrenheit = ""
convenience init(celsius: String, fahrenheit: String) {
self.init()
self.celsius = celsius
self.fahrenheit = fahrenheit
}
required convenience init?(_ map: Map) {
self.init()
}
}
extension Temperature {
class ObservableType {
private(set) var celsius = Observable("")
private(set) var fahrenheit = Observable("")
init(_ type: Temperature) {
self.celsius.next(type.celsius)
self.fahrenheit.next(type.fahrenheit)
}
}
func observableType() -> Temperature.ObservableType {
return ObservableType(self)
}
}
extension Temperature: Mappable {
func mapping(map: ObjectMapper.Map) {
self.celsius <- map["celsius"]
self.fahrenheit <- map["fahrenheit"]
}
}
本来のValueObjectとしては、enumは正しいでしょう。
enum Exception: ErrorType {
// なし
case None
// 予期せぬ例外。回復不能な例外。
case Unexpected
// プログラム例外。回復不能な例外。
case ProgramError
// Realmインスタンス初期化例外。
// 一般的なディスクI/Oの処理と同様に、Realmインスタンスの作成はリソースが不足している環境下では失敗する可能性があります。実際は、各スレッドにおいて最初にRealmインスタンスを作成しようとするときだけエラーが起こる可能性があります。
case LackResources
case NotFound
}
その他よく作る正しいValueObjectとしては、例えばUIDeviceの情報を定数で保持するstructです。(もちろん、Deviceに関連するメソッドを持たせます)
こうするメリットはUIDevice(説明のためUIDeviceを使いましたが、他のクラスでも同様)のiosのバージョンによる違いをこのクラスで吸収できることです。
色々なクラスに点在するよりも一つのクラスにまとまっていた方が対応が簡単になるでしょう。
import UIKit
struct Device {
let iosVersion = UIDevice.currentDevice().systemVersion
let modelName = UIDevice.currentDevice().model
・・・
サービスロケーター
最初に話していたサービスロケーターです。このクラスのおかげでレイヤーアーキテクチャを簡単に維持できます。
命名規約(実装にはprotocol名+Implとしている)で取得していることに注意してください。
また、add
メソッドでclosureを渡すようにし、lookup
時にclosureを呼び出すことになるので、都度インスタンスが生成されます。
同じインスタンスを返したい場合は、T型を引数に渡す add
メソッドを用意するといいでしょう。
protocol ServiceLocator {
static var sharedInstance: ServiceLocator { get }
func add<T>(recipe: () -> T)
func lookup<T>() -> T
}
class ServiceLocatorImpl: ServiceLocator {
private lazy var container: Dictionary < String, () -> Any > = [:]
private static let singleton = ServiceLocatorImpl()
static var sharedInstance: ServiceLocator { return singleton }
private init() {
}
func add<T>(recipe: () -> T) {
let key = typeName(T)
container[key] = recipe
}
func lookup<T>() -> T {
let key = typeName(T)
return container[key]!() as! T
}
private func typeName<T>(some: T.Type) -> String {
// 命名規約で動作
return "\(some)".stringByReplacingOccurrencesOfString("Impl", withString: "")
}
}
まとめ
この記事のようにアーキテクチャでの仕組みをきちんと作っておくと本質的な実装をするだけで済むようになります。
例えば、ほとんどの処理は共通化されるので、実際に作成するのは、以下だけになります。
- Repositoryのprotocolとclass
- API呼び出しにつき、メソッド、1行。
- API呼び出しにつき、一つのFetchContextクラス
- 必要であれば複雑なDB検索メソッド
- Controller
- Bondとドメインの接続
- ドメインの監視と画面更新
- ボタン押下などでアプリケーション層の呼び出し
- アプリケーション層のService
- トランザクション単位でまとめられたドメインメソッドの呼び出し
- エラーを伝えるBondのプロパティの設定
- EntityとValueObject
- Bondのプロパティ
- DBに保存するプロパティ
- ドメイン固有の処理・ドメイン状態の変更
このうちほとんどの処理はドメイン層に記述されていくはずです。
つまり本質的なロジックに集中しやすくなるのではないかと思います。