All Articles

iOSDC2019 - Swiftクリーンコードアドベンチャーの復習(Protocol編)

はじめに

本記事は、 iOSDC 2019 年の Swift クリーンコードアドベンチャー ~日々の苦悩を乗り越え、確かな選択をするために~ で紹介された、クリーンコードを書く際の Protocol 編(前編)についてまとめたものになります。

前準備

手を動かす目的で Mock 用のサーバーを用意したいため、 mocky を使わせてもらいます。

/users

http://www.mocky.io/v2/5db79a153b00006d0035eb91

[
    {
        "id": 1,
        "name": "Jhon"
    },
    {
        "id": 2,
        "name": "Ketty"
    },
    {
        "id": 3,
        "name": "Smith"
    }
]

/items

http://www.mocky.io/v2/5db79c8a3b0000710035eb95

[
    {
        "id": 1,
        "name": "Kindle"
    },
    {
        "id": 2,
        "name": "PS4"
    },
    {
        "id": 3,
        "name": "Macbook Pro"
    }
]

変更前のオリジナルコード

課題

このコードの課題は、Item と User がほとんど同じであるにも関わらず冗長に、 fetchItemfetchUser を書いてしまっている。確かにこのように同じようなコードを書くとエンジニアとしてやってしまった感があるので注意したいです。

struct Item: Codable {
    let id: Int
    let name: String
}

struct User: Codable {
    let id: Int
    let name: String
}

struct Client {
    let baseURL = URL(string: "https://xxxxx")!
}

extension Client {
    func fetchItem(id: Int, completion: @escaping (Result<Item, Error>) -> Void) {
        let urlRequest = URLRequest(url: baseURL
            .appendingPathComponent("items")
            .appendingPathComponent("\(id)")
        )
        let session = URLSession.shared
        session.dataTask(with: urlRequest){ (data, _, error) in
            if let error = error {
                completion(.failure(error))
            } else if let data = data {
                let decoder = JSONDecoder()
                completion(Result { try decoder.decode(Item.self, from: data)})
            }
        }.resume()
    }

    func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
        let urlRequest = URLRequest(url: baseURL
            .appendingPathComponent("users")
            .appendingPathComponent("\(id)")
        )
        let session = URLSession.shared
        session.dataTask(with: urlRequest){ (data, _, error) in
            if let error = error {
                completion(.failure(error))
            } else if let data = data {
                let decoder = JSONDecoder()
                completion(Result { try decoder.decode(User.self, from: data)})
            }
        }.resume()
    }
}

リファクタリング

Step1: Protocol を使って共通化する

Fetchable という Protocol を作成して同じような処理を共通化しています。

コンパイルエラーを防ぐために、 引数に Model.Type を渡しているところがポイントです。

extension Client {
    // 引数でModelのタイプを渡す形でコンパイルエラーを解決させる
    func fetch<Model: Fetchable>(_: Model.Type, id: Int, completion: @escaping (Result<Model, Error>) -> Void) {
        let urlRequest = URLRequest(url: baseURL
            .appendingPathComponent(Model.apiBase)
            .appendingPathComponent("\(id)")
        )
        let session = URLSession.shared
        session.dataTask(with: urlRequest){ (data, _, error) in
            if let error = error {
                completion(.failure(error))
            } else if let data = data {
                let decoder = JSONDecoder()
                completion(Result { try decoder.decode(Model.self, from: data)})
            }
        }.resume()
    }

    func fetchItem(id: Int, completion: @escaping (Result<Item, Error>) -> Void) {
        return Client().fetch(Item.self, id: id, completion: completion)
    }

    func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
        return Client().fetch(User.self, id: id, completion: completion)
    }

}

protocol Fetchable: Decodable {
    static var apiBase: String { get }
}
extension User: Fetchable {
    static var apiBase: String { return "users" }
}
extension Item: Fetchable {
    static var apiBase: String { return "items" }
}

Step2: テスト可能なコードに書き換え

ポイント: 元からある Apple が用意した URLSession を今回作成する URLSessionProtocol に準拠させることにより、後で実装を自由に行えるようにしている。 extension URLSession: URLSessionProtocol {} ← これが最大のポイント

struct Client {
    let baseURL = URL(string: "https://xxxxx")!

    // Mockと差し替えられる用にイニシャライズ時にURLSessionProtocolを渡している。
    let session: URLSessionProtocol
    init(session: URLSessionProtocol) {
        self.session = session
    }
}

extension Client {

    func fetch<Model: Fetchable>(_: Model.Type, id: Int, completion: @escaping (Result<Model, Error>) -> Void) {
        let urlRequest = URLRequest(url: baseURL
            .appendingPathComponent(Model.apiBase)
            .appendingPathComponent("\(id)")
        )
        session.dataTask(with: urlRequest){ (data, _, error) in
            if let error = error {
                completion(.failure(error))
            } else if let data = data {
                let decoder = JSONDecoder()
                completion(Result { try decoder.decode(Model.self, from: data)})
            }
        }.resume()
    }

    func fetchItem(id: Int, completion: @escaping (Result<Item, Error>) -> Void) {
        return Client(session: URLSession.shared).fetch(Item.self, id: id, completion: completion)
    }

    func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
        return Client(session: URLSession.shared).fetch(User.self, id: id, completion: completion)
    }

}

protocol Fetchable: Decodable {
    static var apiBase: String { get }
}
extension User: Fetchable {
    static var apiBase: String { return "users" }
}
extension Item: Fetchable {
    static var apiBase: String { return "items" }
}

// URLSessionからそのままコピーしたメソッドを持つプロトコル
protocol URLSessionProtocol {
    func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
}

extension URLSession: URLSessionProtocol {}

Step3: Protocol をより活用しよう

動画の中では、 「Mock はある Class をテストするための Object を模倣するためのものであり、問題を解決するための Generic な Protocol とは目的が異なります。」と言われております。

また、Step2 の解決策では、URLSession に準拠した https のリクエストのみでしか使えないので、より汎用的な形にする方法として紹介しております。

ポイント: 先程は、URLSession で用意されているfunc dataTask を差し替えられる形にしておりましたが、ここでは、func sendを持つTransport プロトコルを用意して、URLSession を Transport プロトコルに準拠させて、func dataTaskfunc send 内に Wrap して隠蔽しております。← これにより外からは URLSession を気にせずに使えます。

struct Client {
    let baseURL = URL(string: "https://xxxxx")!
    let transport: Transport
    // tips: デフォルト引数を足すことで既存の箇所を修正しなくて適合できるようになる。
    init(transport: Transport = URLSession.shared) {
        self.transport = transport
    }
}

protocol Fetchable: Decodable {
    static var apiBase: String { get }
}
extension User: Fetchable {
    static var apiBase: String { return "users" }
}
extension Item: Fetchable {
    static var apiBase: String { return "items" }
}

// sendメソッドが唯一の機能
// Foundation内の型のみを使用
// URLRequest, Result<Data, Error>
protocol Transport {
    func send(request: URLRequest, completion: @escaping (Result<Data, Error>) -> Void)
}

extension Client {
    func fetch<Model: Fetchable>(_: Model.Type, id: Int, completion: @escaping (Result<Model, Error>) -> Void) {
        let urlRequest = URLRequest(url: baseURL
            .appendingPathComponent(Model.apiBase)
            .appendingPathComponent("\(id)")
        )
        transport.send(request: urlRequest) { result in
            let decoder = JSONDecoder()
            completion(Result { try decoder.decode(Model.self, from: result.get()) })
        }
    }

    func fetchItem(id: Int, completion: @escaping (Result<Item, Error>) -> Void) {
        Client().fetch(Item.self, id: id, completion: completion)
    }

    func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
        Client().fetch(User.self, id: id, completion: completion)
    }

}

extension URLSession: Transport {
    func send(request: URLRequest, completion: @escaping (Result<Data, Error>) -> Void) {
        // ポイント: URLSessionのdataTaskをWrapしている。
        dataTask(with: request){ (data, _, error) in
            if let error = error {
                return completion(.failure(error))
            }
            if let data = data {
                completion(.success(data))
            }
        }.resume()
    }

}

まとめ

動画自体は続きますが、ここでは Protocol の Retroactive modeling を実に効果的に使われており勉強になった為、整理のため Blog にまとめました。

リファクタリングの過程で示されていた技として、

Apple が提供している元から Class に対して

  • 作成した Protocol に準拠させる extension Apple提供class: 作成したprotocol
  • 作成した protocol に関数を用意し、この protocol 内の func で Apple 提供 class に存在する関数を Wrap する
extension Apple提供class: 作成したprotocol{
   func 作成したfunc() {
     // Apple提供classに存在する関数
     appleSupplyFunc {
     }
   }
}

これは非常に有用な技なので記憶に定着して使いこなして行きたいです!