iOS
api
Swift
Alamofire
RxSwift

APIのRequestProtocolの実装案

概要

可読性の高くかつ冗長にならないEncodableを使ったRequestの設計を考えてみました。

目的

APIのRequestを作る際にパッと見てRequestが何をしているのかがわかるようにしたかったのと、Requestを作る際に毎回QueryParameterをイニシャライザの引数に書いたり、nil時の処理やDictionaryに格納する処理を書いたりするのが面倒なのでより良いRequestProtocolの実装になるように設計しました。

RequestProtocolの実装

(Requestの実装にAlamofireを使っています。)
細かい使い方や説明は後の方に書いています。

Request.swift
protocol Request {
    associatedtype Response
    associatedtype Serializer: DataResponseSerializerProtocol where Self.Serializer.SerializedObject == Self.Response
    associatedtype Query: Encodable

    var method: HTTPMethod { get }      // HTTPMethodはAlamofireのクラスです
    var host: Hosts { get }             // Hostsも自前で実装してください(おまけ参照)
    var path: String { get }
    var query: Query { get }
    var httpBody: Data? { get }
    var setting: APISetting { get }     // Header情報などを付与するための設定クラス(自前で実装してください。おまけ参照)
    var serializer: Serializer { get }  // Serializerを設定するとDecodable以外のものにも対応できます
}

struct EmptyQuery: Encodable {}
struct EmptyResponse: Decodable {}

extension Request {
    var query: EmptyQuery { return EmptyQuery() }
    var httpBody: Data? { return nil }

    var urlString: String {
        let url = URL(string: host.urlString + path)!
        var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
        if method == .get {
            do {
                var queryItems: [URLQueryItem] = try QueryEncoder().encode(query)
                if let items = urlComponents?.queryItems {
                    queryItems = items + queryItems
                }
                urlComponents?.queryItems = queryItems
            } catch {
            }
        }
        return urlComponents?.url?.absoluteString ?? url.absoluteString
    }
}

extension Request where Response: Decodable {
    var serializer: DecodableSerializer<Response> {
        return DecodableSerializer<Response>()
    }
}

QueryEncoderに関して

Encodableを使ってQueryの自動マッピングができるようにQueryEncoderを作成しました。Swift@IBMのKituraを参考にしています。

QueryEncoder.swift
extension CharacterSet {
    static let customURLQueryAllowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~=:&")
}

public class QueryEncoder: Encoder {

    private func getFieldName(from codingPath: [CodingKey]) -> String {
        return codingPath.compactMap({ $0.stringValue }).joined(separator: ".")
    }

    private var dictionary: [String: String]
    public var codingPath: [CodingKey] = []
    public var userInfo: [CodingUserInfoKey: Any] = [:]
    public let dateFormatter: DateFormatter?
    public init(dateFormatter: DateFormatter? = nil) {
        self.dictionary = [:]
        self.dateFormatter = dateFormatter
    }

    // こちらで任意の型のqueryを渡し[URLQueryItem]としてqueryを出力します。
    public func encode<T: Encodable>(_ value: T) throws -> [URLQueryItem] {
        let dict: [String: String] = try encode(value)
        return dict.reduce([URLQueryItem]()) { array, element in
            var array = array
            array.append(URLQueryItem(name: element.key, value: element.value))
            return array
        }
    }

    // ここで全ての値に対してパースしてDictionaryで返しています
    public func encode<T: Encodable>(_ value: T) throws -> [String: String] {
        let fieldName = getFieldName(from: codingPath)

        switch value {
        /// Ints
        case let fieldValue as Int:
            self.dictionary[fieldName] = String(fieldValue)
        case let fieldValue as Int8:
            self.dictionary[fieldName] = String(fieldValue)
        case let fieldValue as Int16:
            self.dictionary[fieldName] = String(fieldValue)
        case let fieldValue as Int32:
            self.dictionary[fieldName] = String(fieldValue)
        case let fieldValue as Int64:
            self.dictionary[fieldName] = String(fieldValue)
        /// Int Arrays
        case let fieldValue as [Int]:
            let strs: [String] = fieldValue.map { String($0) }
            self.dictionary[fieldName] = strs.joined(separator: ",")
        case let fieldValue as [Int8]:
            let strs: [String] = fieldValue.map { String($0) }
            self.dictionary[fieldName] = strs.joined(separator: ",")
        case let fieldValue as [Int16]:
            let strs: [String] = fieldValue.map { String($0) }
            self.dictionary[fieldName] = strs.joined(separator: ",")
        case let fieldValue as [Int32]:
            let strs: [String] = fieldValue.map { String($0) }
            self.dictionary[fieldName] = strs.joined(separator: ",")
        case let fieldValue as [Int64]:
            let strs: [String] = fieldValue.map { String($0) }
            self.dictionary[fieldName] = strs.joined(separator: ",")
        /// UInts
        case let fieldValue as UInt:
            self.dictionary[fieldName] = String(fieldValue)
        case let fieldValue as UInt8:
            self.dictionary[fieldName] = String(fieldValue)
        case let fieldValue as UInt16:
            self.dictionary[fieldName] = String(fieldValue)
        case let fieldValue as UInt32:
            self.dictionary[fieldName] = String(fieldValue)
        case let fieldValue as UInt64:
            self.dictionary[fieldName] = String(fieldValue)
        /// UInt Arrays
        case let fieldValue as [UInt]:
            let strs: [String] = fieldValue.map { String($0) }
            self.dictionary[fieldName] = strs.joined(separator: ",")
        case let fieldValue as [UInt8]:
            let strs: [String] = fieldValue.map { String($0) }
            self.dictionary[fieldName] = strs.joined(separator: ",")
        case let fieldValue as [UInt16]:
            let strs: [String] = fieldValue.map { String($0) }
            self.dictionary[fieldName] = strs.joined(separator: ",")
        case let fieldValue as [UInt32]:
            let strs: [String] = fieldValue.map { String($0) }
            self.dictionary[fieldName] = strs.joined(separator: ",")
        case let fieldValue as [UInt64]:
            let strs: [String] = fieldValue.map { String($0) }
            self.dictionary[fieldName] = strs.joined(separator: ",")
        /// Floats
        case let fieldValue as Float:
            self.dictionary[fieldName] = String(fieldValue)
        case let fieldValue as [Float]:
            let strs: [String] = fieldValue.map { String($0) }
            self.dictionary[fieldName] = strs.joined(separator: ",")
        /// Doubles
        case let fieldValue as Double:
            self.dictionary[fieldName] = String(fieldValue)
        case let fieldValue as [Double]:
            let strs: [String] = fieldValue.map { String($0) }
            self.dictionary[fieldName] = strs.joined(separator: ",")
        /// Bools
        case let fieldValue as Bool:
            self.dictionary[fieldName] = String(fieldValue)
        case let fieldValue as [Bool]:
            let strs: [String] = fieldValue.map { String($0) }
            self.dictionary[fieldName] = strs.joined(separator: ",")
        /// Strings
        case let fieldValue as String:
            self.dictionary[fieldName] = fieldValue
        case let fieldValue as [String]:
            self.dictionary[fieldName] = fieldValue.joined(separator: ",")
        /// Dates
        case let fieldValue as Date:
            self.dictionary[fieldName] = dateFormatter?.string(from: fieldValue)
        case let fieldValue as [Date]:
            let strs: [String] = fieldValue.compactMap { dateFormatter?.string(from: $0) }
            self.dictionary[fieldName] = strs.joined(separator: ",")
        default:
            if fieldName.isEmpty {
                self.dictionary = [:]   // Make encoder instance reusable
                try value.encode(to: self)
            } else {
                do {
                    try value.encode(to: self)
                } catch let error {
                    throw encodingError(value, underlyingError: error)
                }
            }
        }
        return self.dictionary
    }
// 以下略(以降はKituraと全く同じ実装)
//・・・
//・・・
}

使い方は以下のようになります。
実際にはQueryEncoderはRequestのProtcolExtensionの中で規定値として実装しているので明示的に実装することはないです
QueryにDate型が含まれている場合Encoderの初期化時にDateFormatterを渡すようにしてください。
(この部分に関しては実際もっと別の実装をしているのですが、長くなるため今回は書きません)

struct Query {
    let offset: Int
    let limit: Int
}

var query = Query(offset: 0, limit: 10)
var queryItems: [URLQueryItem] = try QueryEncoder(dateFormatter: dateFormatter).encode(query)
var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
urlComponents?.queryItems = queryItems

Requestの作成方法

Queryが複雑な時の場合

https://test.jp/test/ranking?totalLimit=48&offset=0&pageLimit=6&field=image,text&type=day,month,year&device_id=aaaaaaaaaaaaaa

Queryの構造体は基本的にRequestの内部に struct Query として定義するようにします。これによりRequestとQueryの一対一関係がわかりやすくなります。
pathに変数がはいる場合などはcomputed propertyにしてinit作成しないようにします。(僕の思想ですがinitをRequest内部に書くとごちゃごちゃしてるように見えるため)

struct TestRankingRequest: Request {
    typealias Response = TestRankingData
    let method: HTTPMethod = .get
    let host: Hosts = .test
    let path = "/test/ranking"
    let setting: APISetting = .default
    var query: Query?

    struct Query: Encodable {
        let totalLimit: Int
        let offset: Int
        let pageLimit: Int
        let age: Int?
        // QueryEncoderのおかげで同じkeyで複数の値を設定する場合もこんな風にかけます。
        let type: [String] = [
            "day",
            "month",
            "year"
        ]
        let field: [String] = [
            "image",
            "text"
        ]
        let deviceID: String = "aaaaaaaaaaaaaa"

        // ここは少しめんどくさいですがCodableの場合と同じで変数名とkeyが違う場合codingkeyをかいてください
        private enum CodingKeys: String, CodingKey {
            case totalLimit
            case offset
            case pageLimit
            case age
            case type
            case field
            case deviceID = "device_id"
        }
    }
}

ちなみに利用するときはこんな感じ

let request = TestRankingRequest(query: .init(totalLimit: 48, offset: offset, pageLimit: 6, age: age))
APIClient.send(request)

さらにちなむとQueryEncoderがない場合こんな感じの実装になり最低っぽくなります。

  • オプショナルチェックをパラメータにしなくてはならない
  • 初期化時の引数を全て列挙しなければならない
  • Dictionary格納時に全てkeyを列挙しなければならない
  • Dictionary格納時に全てString変換を書かなければならない
struct TestRankingRequest: Request {
    typealias Response = TestRankingData

    let method: HTTPMethod = .get
    let host: Hosts = .test
    let path = "/test/ranking"
    let query: [String : String]?
    let httpBody: Data? = nil
    var setting: APISetting = .default

    init(totalLimit: Int, offset: Int, limit: Int, age: Int?, type: [String], field: [String]) {
        query = [
            "offset": "\(offset)",
            "pageLimit": "\(limit)",
            "totalLimit": "\(totalLimit)",
            "device_id": "aaaaaaaaaaaaaa"
        ]
        let typeString = type.joined(separator: ",")
        let fieldString = field.joined(separator: ",")
        if !type.isEmpty {
            query?.append(["type": "\(typeString)"])
        }
        if !field.isEmpty {
            query?.append(["field": "\(typeString)"])
        }  
        if let age = age {
            query?.append(["age": "\(age)"])
        }
    }
}

Queryが複雑でない場合

https://test.jp/test/ranking?limit=20&offset=0
struct TestRankingRequest: Request {
    typealias Response = TestRankingData
    let method: HTTPMethod = .get
    let host: Hosts = .test
    var path = "/test/public/ranking"
    var query: Query?

    struct Query: Encodable {
        let limit: Int
        let offset: Int
    }
}

Queryがない場合

https://test.jp/test/ranking

Queryが一切ない場合に毎回空のQueryを定義するのが冗長だと感じたので初期値として空のQueryを設定しています。(Request.swiftのEmptyQuery参照)
実際に作成する時は下記のようになり、だいぶスッキリしたRequestになりました。

struct TestRankingRequest: Request {
    typealias Response = TestRankingData
    let method: HTTPMethod = .get
    let host: Hosts = .test
    let path = "/test/guests/ranking/"
    // Queryがない時は省略できる
}

おまけ

今回はRequestProtocolの設計の話なのでAPIClientの内部の実装やHostsの実装については言及しませんが、参考までにいくつかソースを載せておきます。RequestにはAlamofireRxSwiftを使っています。

APIClient.swift
struct APIClient {
    private static let manager: Alamofire.SessionManager = {
        var configuration = URLSessionConfiguration.default
        configuration.timeoutIntervalForRequest = 15

        var headers = Alamofire.SessionManager.defaultHTTPHeaders
        headers["User-Agent"] = // ユーザエージェントを入れる
        configuration.httpAdditionalHeaders = headers

        return Alamofire.SessionManager(configuration: configuration)
    }()

    static func send<T: Request>(_ request: T) -> Observable<T.Response> {
        let urlRequest = URLRequest.instantiate(request.method, urlString: request.urlString, setting: request.setting, httpBody: request.httpBody)
        return Single.create(subscribe: { event in
            resume(urlRequest, setting: request.setting, serializer: request.serializer) { (response: DataResponse<T.Response>) in
                switch response.result {
                case .success(let data):
                    event(.success(data))
                case .failure(let error):
                    event(.error(error))
                }
            }
            return Disposables.create()
        }).asObservable()
    }

    private static func resume<T: DataResponseSerializerProtocol>(_ urlRequest: URLRequest, setting: APISetting, serializer: T, completion: @escaping (DataResponse<T.SerializedObject>) -> ()) {
        manager.request(urlRequest).response(responseSerializer: serializer) { (response: DataResponse<T.SerializedObject>) in
            // responseのハンドリング処理はここで書く(クライアントエラーなど)
            completion(response)
        }
    }
}
URLRequestExtension.swift
extension URLRequest {
    static func instantiate(_ method: Alamofire.HTTPMethod,
                            urlString: String,
                            setting: APISetting = APISetting(),
                            cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy,
                            httpBody: Data? = nil
        ) -> URLRequest {
        var request = self.init(url: URL(string: urlString), cachePolicy: cachePolicy, timeoutInterval: 60)

        request.httpMethod = method.rawValue

        /// headers
        var finalHeaders: [String: String] = headers ?? [:]
        finalHeaders.append(setting.headers)
        finalHeaders.forEach { key, value in
            request.setValue(value, forHTTPHeaderField: key)
        }

        /// body
        request.httpBody = httpBody

        return request
    }
}
APISetting.swift
struct APISetting {
    let autoLogin: Bool
    let needsToken: Bool
    let retryCount: Int
    let apiType: APIType

    init(autoLogin: Bool = false, needsToken: Bool = false, retryCount: Int = 3, apiType: APIType = .test) {
        self.autoLogin = autoLogin
        self.needsToken = needsToken
        self.retryCount = retryCount
        self.apiType = apiType
    }
}

extension APISetting {
    var headers: [String: String] {
        // APITypeにheadersを持たせています
        return apiType.headers(needsAuthorization: needsToken)
    }
}
Hosts.swift
enum Hosts: String {
    case test = "test.jp"
    case sample = "sample.jp" 
    case qiita = "qiita.com"

    var urlString: String {
        return "https://" + self.rawValue
    }
}

おわりに

今回APIのRequest周りの設計を考えてみました。
今まで使っていたものより可読性が向上し、チーム全体としての実装速度も向上したように感じます。
実際にチームメンバーからはこのRequestProtocolは好評で僕自身使いやすいので今回設計を考えた甲斐があったなあという感想です。
気をつけた点としては、下記のようなものがあげられます。

  1. コードの書く量を少なくしようと様々なラップ実装をして可読性がさがったり、後の運用コストが大きくなる(独自実装によるブラックボックスが増えたりするため)といったことが起こらないようにしたこと
  2. Requestを作る際に自然な形で実装できる(余計な手間を感じさせない)こと
  3. 後から見た人が理解しやすいようにすること(1と関連してます)

もし、Requestの設計を考えている人がいたらぜひ参考にしていただけると嬉しいです。
またより良い実装があればご意見もいただけると助かります。