0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftAdvent Calendar 2023

Day 10

依存性逆転の原則を使った場合、エラーハンドリングはどうすればいいのか

Last updated at Posted at 2023-12-09

はじめに

依存性逆転の原則で抽象化したモジュールがエラーを起こす可能性がある場合、Swiftではどのようにすればいいのか迷ったので考えてみました。
結論は出ていません。

依存性逆転の原則については、Wikipediaを参考にします。

概要

ベースとして、公式にあるVendingMachineとVendingMachineErrorによるコードを利用します。

Wikipediaの依存性逆転パターンの部分、

低レベルレイヤーはこれらの抽象クラスやインターフェースを継承して生成される。

という内容に従うため、まずはVendingMachineをprotocol化し、従来書いていたコードを LocalVendingMachine に変更します。

enum VendingMachineError: Error {
    case invalidSelection
    case insufficientFunds(coinsNeeded: Int)
    case outOfStock
}

protocol VendingMachine {
    func vend(itemNamed name: String) throws
}

class LocalVendingMachine: VendingMachine {
    var inventory = [
        "Candy Bar": Item(price: 12, count: 7),
        "Chips": Item(price: 10, count: 4),
        "Pretzels": Item(price: 7, count: 11)
    ]
    var coinsDeposited = 0

    func vend(itemNamed name: String) throws  {
        guard let item = inventory[name] else {
            throw VendingMachineError.invalidSelection
        }

        guard item.count > 0 else {
            throw VendingMachineError.outOfStock
        }

        guard item.price <= coinsDeposited else {
            throw VendingMachineError.insufficientFunds(coinsNeeded: item.price - coinsDeposited)
        }

        coinsDeposited -= item.price

        var newItem = item
        newItem.count -= 1
        inventory[name] = newItem

        print("Dispensing \(name)")
    }
}

これで、呼び出し側はLocalVendingMachineの実装に依存せず、VendingMachineのprotocolに依存するようになりました。

let vendingMachine: VendingMachine // ここにLocalVendingMachineなどを注入

let favoriteSnacks = [
    "Alice": "Chips",
    "Bob": "Licorice",
    "Eve": "Pretzels",
]

func buyFavoriteSnack(person: String, vendingMachine: VendingMachine) throws {
    let snackName = favoriteSnacks[person] ?? "Candy Bar"
    try vendingMachine.vend(itemNamed: snackName)
}

do {
    try buyFavoriteSnack(person: "Alice", vendingMachine: vendingMachine)
    print("成功! おいしい。")
} catch VendingMachineError.invalidSelection {
    print("無効な選択です。")
} catch VendingMachineError.outOfStock {
    print("在庫切れです。")
} catch VendingMachineError.insufficientFunds(let coinsNeeded) {
    print("お金が足りません。あと \(coinsNeeded) コイン投入してください。")
} catch {
    print("予期しないエラー: \(error)。")
}

疑問

呼び出し側が実装に直接依存しなくなったので、VendingMachineのprotocolに適合したクラスであれば差し替えが可能になりました。
しかし、その分VendingMachineのprotocolに適合したクラスが独自のエラーを出す場合が考えられます。

例えば、ネットワーク経由で接続されるVendingMachineの適合クラス NetworkVendingMachine に差し替えることを考えましょう。

struct NetworkVendingMachine: VendingMachine {
    func vend(itemNamed name: String) throws  {
        throw ネットワークのエラー
    }
}

NetworkVendingMachineは当然ネットワークに失敗することもありえ、それはエラーとしてthrowされることになります。そのエラー種別はどうするべきでしょうか?

VendingMachineErrorに新たに定義

enum VendingMachineError: Error {
    case invalidSelection
    case insufficientFunds(coinsNeeded: Int)
    case outOfStock
    case netWorkOffline //新たに追加
}
struct NetworkVendingMachine: VendingMachine {
    func vend(itemNamed name: String) throws  {
        throw VendingMachineError.netWorkOffline
    }
}

このように、VendingMachineErrorに新たにcaseを追加することで、VendingMachineの呼び出し側はVendingMachineErrorのハンドリングを行うことで処理が可能になります。

しかし新たなVendingMachine適合クラスが増えるたびにVendingMachineErrorのcaseを増やすことになるので、VendingMachineErrorはVendingMachine適合クラスの実装全てのエラーをサポートすることになります。

VendingMachineErrorのprotocol化

VendingMachineErrorもprotocol化して、起こるエラーをVendingMachineErrorに適合させるパターンです。

protocol VendingMachineError: Error {}

struct OutOfStockError: VendingMachineError {}
struct InvalidSelectionError: VendingMachineError {}
struct InsufficientFundsError: VendingMachineError {
    let coinsNeeded: Int
}

extension VendingMachineError {
    static var invalidSelection: InvalidSelectionError {
        .init()
    }
    
    static var outOfStock: OutOfStockError {
        .init()
    }
    
    static func insufficientFunds(coinsNeeded: Int) -> InsufficientFundsError {
        .init(coinsNeeded: coinsNeeded)
    }
}
// ネットワークエラーの新設
struct NetworkOfflineError: VendingMachineError {}

extension VendingMachineError {
    static var networkOffline: NetworkOfflineError {
        .init()
    }
}

struct NetworkVendingMachine: VendingMachine {
    func vend(itemNamed name: String) throws  {
        throw VendingMachineError.netWorkOffline
    }
}

こうすることで、実装クラスはVendingMachineErrorという抽象エラーに適合したものをthrowすることになります。VendingMachineErrorは実装に依存することはありません。

しかし、呼び出し側のコードは各種具体的なエラー型に依存することになります。
呼び出し側で、独自にハンドリングしたいエラーを指定し、個別対応を行うことになります。

do {
    try buyFavoriteSnack(person: "Alice", vendingMachine: vendingMachine)
    print("成功! おいしい。")
} catch _ as InvalidSelectionError {
    print("無効な選択です。")
} catch _ as OutOfStockError {
    print("在庫切れです。")
} catch let error as InsufficientFundsError {
    print("お金が足りません。あと \(error.coinsNeeded) コイン投入してください。")
} catch _ as NetworkOfflineError {
    print("インターネットに接続されていません")
} catch {
    print("予期しないエラー: \(error)。")
}

VendingMachineErrorに共通メッセージを用意した場合

エラーのプロトコルに共通的な処理を持たせることで、呼び出し側がprotocolのみに依存した処理を書くことが可能です。
例えばエラーメッセージを持たせるようにして、VendingMachineErrorの実装を見ないことも可能になります。

protocol VendingMachineError: Error {
    var message: String { get }
}
do {
    try buyFavoriteSnack(person: "Alice", vendingMachine: vendingMachine)
    print("成功! おいしい。")
} catch _ as VendingMachineError {
    print(error.message)
} catch {
    print("予期しないエラー: \(error)。")
}

しかし、この場合のエラーメッセージはあくまでVendingMachineが呼び出し側にエラーを起こした理由、LocalizedErrorでいうfailureReasonを伝える内容であり、呼び出し側が最終的に表示したいメッセージとは異なる可能性があります。

例えばVendingMachineがネットワークエラーの時、呼び出し側がユーザーにメッセージを表示する役割を担っている場合、更新ボタンを押すべきなのか、それともPull to refreshをするべきなのかVendingMachineの実装がメッセージの内容を設定するのは不可能です。
LocalizedErrorでいうrecoverySuggestionの部分は呼び出し側によって異なるので、その場合は呼び出し側が実装のエラーを見てメッセージを設定する選択になるんじゃないかと思います。

まとめ

以上、大きく分けて2パターンについて考えてみました。
個人的にはVendingMachineErrorをprotocol化する方を使おうと思います。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?