All Articles

Git リポジトリ検索APIをステップ・バイ・ステップでリファクタリング

はじめに

検証コードを書く時に Github API を使うことが多いですが、API の種類を増やすのにコピー&ペーストして作成してしまっています。

”コピー&ペーストで重複したコードを増やさないようにしたい”、また、”Json からデータ取得するテストコードへの差し替え可能なコードにしたい” という動機からリファクタリングをしていきます。

今回リファクタリングするにあたっては、 【iOSDC2019 補足資料】具体的なコードから始めよ ~今の問題を解決し、ジェネリックなコードを見出す を参考にさせてもらっています。 いつも、とても勉強になる投稿本当にありがとうございます。

まずリファクタリング前のオリジナルのコードを見てみましょう。

リポジトリ検索のオリジナルコード

import Combine
import Foundation

protocol RepoService {
    func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error>
}

class RepoServiceImpl: RepoService {
    private let session: URLSession
    private let decoder: JSONDecoder

    init(session: URLSession = .shared, decoder: JSONDecoder = .init()) {
        self.session = session
        self.decoder = decoder
    }

    func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> {
        guard
            var urlComponents = URLComponents(string: "https://api.github.com/search/repositories")
        else { preconditionFailure("Can't create url components...") }

        urlComponents.queryItems = [
            URLQueryItem(name: "q", value: query)
        ]

        guard
            let url = urlComponents.url
        else { preconditionFailure("Can't create url from url components...") }

        return session
            .dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: RepoResponse.self, decoder: decoder)
            .map { $0.items }
            .eraseToAnyPublisher()
    }
}

struct RepoResponse: Decodable {
   let items: [Repo]
}

Combine フレームワークを使っているので、スッキリ書けています。 この API 一本だけなら、このように書くので良いと思います。

ただ、ユーザー検索 API を追加する時、上のコードをコピーして、以下の 3 箇所を変更することになり、共通点がとても多いことに気づきます。

  • エンドポイント変更
 var urlComponents = URLComponents(string: "https://api.github.com/search/repositories")var urlComponents = URLComponents(string: "https://api.github.com/search/users")
  • エンティティの型の変更
 -> AnyPublisher<[Repo], Error> {-> AnyPublisher<[User], Error> {
  • デコーダーの型の変更
.decode(type: RepoResponse.self, decoder: decoder).decode(type: UserResponse.self, decoder: decoder)

また、テストコードを書く場合にどうしたら良いのかも検討したいと思います。

コードの改善

共通部分の洗い出し&方針

  • API の URL は、 /repositories /users のエンドポイント以外は完全一致しているので、エンドポイントを切り出せば良さそう。
  • Decode 時に指定している型が RepoResponseUserResponse と異なりますが、Generics を使えば解決できそう。
  • 戻りの型が -> AnyPublisher<[Repo], Error> となっていますが、これも Generics を使えば解決できそう。

オフィシャルページを確認してみます。今回使用するこの 2 つはSearch機能に属する API です。パラメータは

  • q
  • sort
  • order

と共通です。

API の Response も items の外側は完全に一致しています。

{
  "total_count": 1,
  "incomplete_results": false,
  "items": [
    {
       // ...
    }
   ]
}

尚、Repositories の API との共通化は検討しないものとします。

共通部分を APIClient として作成

APIのレスポンスを格納している、RepoResponseitems のみをプロパティに持つ Struct です。

struct RepoResponse: Decodable {
   let items: [Repo]
}

汎用化するにあたり、Repoassociatedtype で表現すれば解決できそうです。 /repositories のエンドポイントを apiBase として定義します。

この 2 点を加味すると以下のような Protocol が作れます。

protocol Fetchable: Decodable {
    associatedtype Response
    static var apiBase: String { get }
    var items: [Response] { get set }
}

Fetchable に準拠させて、RepoResponse は以下のように書きかえられます。 今回は、型推論が効くので typealias を指定を省略しています。

struct RepoResponse: Fetchable {
    var items: [Repo]
    static var apiBase: String { "/repositories" }
}

次に、RepoServiceImplUserServiceImpl で共通部分を抽出すると以下のようになります。

final class APIClient {
    let baseURL = "https://api.github.com/search"
    let session = URLSession.shared

    func fetch<Model>(_: Model.Type, query: String) -> AnyPublisher<[Model.Response], Error> where Model: Fetchable {
        guard var urlComponents = URLComponents(string: "\(baseURL)\(Model.apiBase)") else {
            preconditionFailure("Can't create url components...")
        }
        urlComponents.queryItems = [
            URLQueryItem(name: "q", value: query)
        ]

        guard let url = urlComponents.url else { preconditionFailure("Cant't create url from url components...") }

        return session.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: Model.self, decoder: JSONDecoder())
            .map { $0.items }
            .eraseToAnyPublisher()
    }
}

Generics の Model は、where Model: Fetchable により、Fetchable に準拠させています。

これにより Model に apiBase を定義していることになるので、API の URL を baseURL + apiBase で表現することができるようになります。

戻り値は -> AnyPublisher<[Model.Response] となります。

decoder は .decode(type: Model.self, decoder: JSONDecoder()) となります(型を指定する必要があるので、 Model.self としています)

func fetch<Model>(_: Model.Type, ...) この Model.Type は関数の内部で使用されていませんが何なのでしょうか。

呼び出し側で、

apiClient.fetch(RepoResponse.self, ...)

とすることで Model == RepoResponse が成り立ちます。 つまり、Generics の型を指定するためのパラメータであることがわかります。 Model ではなく、Model.Type としているのは、インスタンスではなく型を受け取るためです。

APIClient に共通部分の処理を移管できましたので、 RepoServiceImpl は以下のように書けます。大分スッキリしました!

final class RepoServiceImpl: RepoService {
    private let apiClient: APIClient
    init() {
        apiClient = APIClient()
    }

    func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> {
        apiClient.fetch(RepoResponse.self, query: query)
    }
}

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

上のコードは、URLSession を使う前提の作りとなっているため、API 通信が必要となってしまっています。

JSON を読み込んでその結果を返却するようなテスト用の Mock コードを作成するには、APIClient が使えないので、 RepoServiceImpl とは独立した以下のような Class を作成する必要があります。

final class RepoServiceMock: RepoService {
    func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> {
        // load data from json
        let data = repoData.items
        if data.isEmpty {
            let error = FetchError.parsing(description: "Couldn't load data")
            return Fail(error: error).eraseToAnyPublisher()
        } else {
            return Future<[Repo], Error> { promise in
                promise(.success(data))
            }.eraseToAnyPublisher()
        }
    }
}

let repoData: RepoResponse = load("repositories.json")

func load<T: Decodable>(_ filename: String, as type: T.Type = T.self) -> T {
    let data: Data

    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
    else {
        fatalError("Couldn't find \(filename) in main bundle.")
    }

    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }

    do {
        let decoder = JSONDecoder()
        return try decoder.decode(T.self, from: data)
    } catch {
        fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
    }
}

APIClient 内部で URLSession が定義されていなければ、この RepoServiceMock は RepoServiceImpl と共通化できそうです。

では、URLSession を使わないように書き換えるにあたり、どの部分まで抽出できるか考えます。

        return session.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: Model.self, decoder: JSONDecoder())
            .map { $0.items }
            .eraseToAnyPublisher()

Decode 部分は Generics で型指定されているので、

return session.dataTaskPublisher(for: url)
            .map { $0.data }

その前の部分を切り出せれば良さそうです。

dataTaskPublisher について調査

dataTaskPublisher が何か調べていきます。

この dataTaskPublisher は URLSession の中に Struct として定義されているため、URLSession ありきのものであることがわかります。

extension URLSession {
 ...
    public func dataTaskPublisher(for url: URL) -> URLSession.DataTaskPublisher

    public struct DataTaskPublisher : Publisher {

        /// The kind of values published by this publisher.
        public typealias Output = (data: Data, response: URLResponse)

        /// The kind of errors this publisher might publish.
        ///
        /// Use `Never` if this `Publisher` does not publish errors.
        public typealias Failure = URLError

        public let request: URLRequest

        public let session: URLSession

        public init(request: URLRequest, session: URLSession)

        /// This function is called to attach the specified `Subscriber` to this `Publisher` by `subscribe(_:)`
        ///
        /// - SeeAlso: `subscribe(_:)`
        /// - Parameters:
        ///     - subscriber: The subscriber to attach to this `Publisher`.
        ///                   once attached it can begin to receive values.
        public func receive<S>(subscriber: S) where S : Subscriber, S.Failure == URLSession.DataTaskPublisher.Failure, S.Input == URLSession.DataTaskPublisher.Output
    }
}

map { $0.data } はどんな返却値なの?

struct DataTaskPublisher : Publisher と定義されているので、 map は Publisher で定義されているものとなります。

extension Publisher {

    /// Transforms all elements from the upstream publisher with a provided closure.
    ///
    /// - Parameter transform: A closure that takes one element as its parameter and returns a new element.
    /// - Returns: A publisher that uses the provided closure to map elements from the upstream publisher to new elements that it then publishes.
    public func map<T>(_ transform: @escaping (Self.Output) -> T) -> Publishers.Map<Self, T>

}

戻り値の型が、 Publishers.Map<Self, T> となっていますが、

T は transform で変換した戻り値の型であることがわかります。今回は Data 型に変換しているので、 T == Data となります。

Self は何でしょうか?これは、自分自身 つまり、 URLSession.DataTaskPublisher となります。

つまり、URLSession の一部の  DataTaskPublisher  を返却する Publisher なので URLSession から独立できません。

ほしいのは Data 型じゃないの?

今回リファクタリングするにこの発想に至るのが時間がかかりました。このままの形ではデータは URLSession から独立できないので、データの型を AnyPublisher<Data, URLError> に変換してしまえば良いのではないでしょうか。

Transport の定義

AnyPublisher<Data, URLError> を戻り値の関数を持つ、Transport プロトコルを定義します。

AnyPublisher に変換するため、 .eraseToAnyPublisher() を追加します。

protocol Transport {
    func fetch(for url: URL) -> AnyPublisher<Data, URLError>
}

extension URLSession: Transport {
    func fetch(for url: URL) -> AnyPublisher<Data, URLError> {
        dataTaskPublisher(for: url).map { $0.data }.eraseToAnyPublisher()
    }
}

良さそうですね。 Retroactive Modelingも活用して、URLSession に fetch 関数を追加しています。これで、APIClient から URLSession を排除できそうです。

APIClient は以下のように書き換えられます。

コンストラクタ時に、URLSession.shared = Transport としているので今まで通り機能します。 リファクタリングする際にパラメータは q 以外に、 sortorder も指定できるように変更しました。

final class APIClient {
    let baseURL = "https://api.github.com/search"
    let transport: Transport

    init(transport: Transport = URLSession.shared) { self.transport = transport }

    func fetch<Model>(_: Model.Type, queries: [URLQueryItem]) -> AnyPublisher<[Model.Response], Error> where Model: Fetchable {
        guard var urlComponents = URLComponents(string: "\(baseURL)\(Model.apiBase)") else {
            preconditionFailure("Can't create url components...")
        }
        if !queries.isEmpty {
            urlComponents.queryItems = queries
        }
        guard let url = urlComponents.url else { preconditionFailure("Cant't create url from url components...") }

        return transport.fetch(for: url)
            .decode(type: Model.self, decoder: JSONDecoder())
            .map { $0.items }
            .eraseToAnyPublisher()
    }
}

コンストラクタとして、URLSession.shared を受け取りますが、fetch 関数の中には URLSession の記述はなくなりました。

利用する側は以下のようになります。

final class RepoServiceImpl: RepoService {
    private let apiClient: APIClient
    init(transport: Transport = URLSession.shared) {
        apiClient = APIClient(transport: transport)
    }

    func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> {
        apiClient.fetch(RepoResponse.self, queries: [URLQueryItem(name: "q", value: query)])
    }
}

テスト用のコードを作成

Json データを読み込んだテストコードは、Transport に準拠させれば良いので、以下のようにシンプルに書けます。これでテスト用のコードでも APIClient を使用するようにできました。

API リクエストして Data 型を取得、Json ファイルを読み込んで Data 型を取得の部分が違うだけで後は処理が共通になりました。

final class TestRepoTransport: Transport {
    func fetch(for url: URL) -> AnyPublisher<Data, URLError> {
        let data = loadRawData("repositories.json")
        return Future<Data, URLError> { callback in
            callback(.success(data))
        }
        .eraseToAnyPublisher()
    }
}


func loadRawData(_ filename: String) -> Data {
    let data: Data
    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
    else {
        fatalError("Couldn't find \(filename) in main bundle.")
    }
    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }
    return data
}

RepoServiceImpl のイニシャライズ時に、 API 通信させたい場合は、RepoServiceImpl()、 Json からデータを取得したい場合は、 RepoServiceImpl(transport: TestRepoTransport()) とするだけで差し替え可能になりました。

ソース全文

  • RepoService
import Combine
import Foundation

protocol RepoService {
    func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error>
}

final class RepoServiceImpl: RepoService {
    private let apiClient: APIClient
    init(transport: Transport = URLSession.shared) {
        apiClient = APIClient(transport: transport)
    }

    func searchPublisher(matching query: String) -> AnyPublisher<[Repo], Error> {
        apiClient.fetch(RepoResponse.self, queries: [URLQueryItem(name: "q", value: query)])
    }
}

// MARK: - Test

final class TestRepoTransport: Transport {
    func fetch(for url: URL) -> AnyPublisher<Data, URLError> {
        let data = loadRawData("repositories.json")
        return Future<Data, URLError> { callback in
            callback(.success(data))
        }
        .eraseToAnyPublisher()
    }
}
  • APIClient
import Combine
import Foundation

final class APIClient {
    let baseURL = "https://api.github.com/search"
    let transport: Transport

    init(transport: Transport = URLSession.shared) { self.transport = transport }

    func fetch<Model>(_: Model.Type, queries: [URLQueryItem]) -> AnyPublisher<[Model.Response], Error> where Model: Fetchable {
        guard var urlComponents = URLComponents(string: "\(baseURL)\(Model.apiBase)") else {
            preconditionFailure("Can't create url components...")
        }
        if !queries.isEmpty {
            urlComponents.queryItems = queries
        }
        guard let url = urlComponents.url else { preconditionFailure("Cant't create url from url components...") }

        return transport.fetch(for: url)
            .decode(type: Model.self, decoder: JSONDecoder())
            .map { $0.items }
            .eraseToAnyPublisher()
    }
}

protocol Fetchable: Decodable {
    associatedtype Response
    static var apiBase: String { get }
    var items: [Response] { get set }
}

protocol Transport {
    func fetch(for url: URL) -> AnyPublisher<Data, URLError>
}

extension URLSession: Transport {
    func fetch(for url: URL) -> AnyPublisher<Data, URLError> {
        dataTaskPublisher(for: url).map { $0.data }.eraseToAnyPublisher()
    }
}
  • Repo
struct Repo: Decodable, Identifiable {
    var id: Int
    let owner: Owner
    let name: String
    let stargazersCount: Int
    let description: String?

    enum CodingKeys: String, CodingKey {
        case id
        case owner
        case name
        case stargazersCount = "stargazers_count"
        case description
    }

    struct Owner: Decodable {
        let avatar: URL

        enum CodingKeys: String, CodingKey {
            case avatar = "avatar_url"
        }
    }
}

// struct RepoResponse: Decodable {
//    let items: [Repo]
// }
struct RepoResponse: Fetchable {
    var items: [Repo]
    static var apiBase: String { "/repositories" }
}
  • LoadData
let repoData: RepoResponse = load("repositories.json")

func loadRawData(_ filename: String) -> Data {
    let data: Data
    guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
    else {
        fatalError("Couldn't find \(filename) in main bundle.")
    }
    do {
        data = try Data(contentsOf: file)
    } catch {
        fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
    }
    return data
}