はじめに
検証コードを書く時に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時に指定している型が
RepoResponse
、UserResponse
と異なりますが、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のレスポンスを格納している、RepoResponse
は items
のみをプロパティに持つStructです。
struct RepoResponse: Decodable {
let items: [Repo]
}
汎用化するにあたり、Repo
をassociatedtype
で表現すれば解決できそうです。
/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" }
}
次に、RepoServiceImpl
と UserServiceImpl
で共通部分を抽出すると以下のようになります。
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
以外に、 sort
、order
も指定できるように変更しました。
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
}