2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

はじめに

検証コードを書く時に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
}
2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?