- 原文作者:Benjamin Encz
- 译文出自:掘金翻译计划
- 译者:Zheaoli
- 校对者:luoyaqifei, Edison-Hsu
私の最初の iOS 開発エンジニアの仕事では、XML パーサーとシンプルなレイアウトツールを作成しました。これらはどちらも宣言型インターフェースに基づいています。XML パーサーは.plist
ファイルに基づいて Objective-C のクラス関係マッピングを実現しました。一方、レイアウトツールは、HTML のようなタグ化された構文を使用してインターフェースのレイアウトを実現することを可能にします(ただし、このツールを使用する前提は、AutoLayout
とCollectionViews
を正しく使用することです)。
これらの 2 つのライブラリは完璧ではありませんが、宣言型コードの 4 つの主要な利点を示しています:
- 関心の分離: 宣言型スタイルで書かれたコードでは意図が宣言されるため、具体的な低レベルの実装に関心を持つ必要がなく、このような分離は自然に発生します。
- 重複コードの削減: すべての宣言型コードは共通のスタイル実装を共有しており、その多くは設定ファイルに属しているため、重複コードによるリスクを減少させることができます。
- 優れた API 設計: 宣言型 API は、ユーザーが既存の実装をカスタマイズできるようにし、既存の実装を固定的な存在として扱うことを避けます。これにより、変更の程度を最小限に抑えることができます。
- 良好な可読性: 正直なところ、宣言型 API に従って書かれたコードは非常に美しいです。
最近私が書いたほとんどの Swift コードは、宣言型プログラミングスタイルに非常に適しています。
特定のデータ構造の記述や特定の機能の実装に関して、私が最も頻繁に使用するタイプは、いくつかのシンプルな構造体です。異なるタイプを宣言することは主にジェネリッククラスに基づいており、これらのものは具体的な機能を実現したり、必要な作業を完了したりする役割を果たします。私たちは PlanGrid の開発プロセスでこの方法を使用して Swift コードを書いています。この開発方法は、コードの可読性の向上と開発者の効率の向上に大きな影響を与えています。
この記事では、PlanGrid アプリケーションで使用されている API 設計について議論したいと思います。これは元々NSOperationQueue を使用して実装されていましたが、現在はより宣言型に近い方法を使用しています。この API について議論することで、宣言型プログラミングスタイルのさまざまな利点を示すことができるでしょう。
Swift で宣言型リクエストシーケンスを構築する#
私たちが再設計した API は、ローカルの変更(オフラインで発生する可能性もあります)を API サーバーと同期させるために使用されます。この変更追跡方法の詳細については議論しませんが、ネットワークリクエストの生成と実行に焦点を当てます。
この記事では、特定のリクエストタイプに焦点を当てたいと思います:ローカルで生成された画像のアップロード。さまざまな要因(この記事の範囲を超えています)を考慮して、画像をアップロードする操作には 3 つのリクエストが含まれます:
- API サーバーにリクエストを送信し、API サーバーは AWS サーバーに画像をアップロードするために必要な情報を応答します。
- 画像を AWS にアップロードします(前回のリクエストから得られた情報を使用)。
- 画像のアップロードが成功したことを確認するために API サーバーにリクエストを送信します。
これらのリクエストシーケンスを含むアップロードタスクがあるので、私たちはそれを特別なタイプに抽象化し、アップロードアーキテクチャがそれをサポートすることに決めました。
リクエストシーケンスプロトコルの定義#
私たちは、ネットワークリクエストシーケンスを記述するために別のタイプを導入することに決めました。このタイプは、リクエストを具体的なネットワークリクエストに変換する役割を持つアップローダークラスによって使用されます(アップローダークラスの実装についてはこの記事では議論しません)。
次に、このタイプは私たちの制御フローの本質です:私たちはリクエストシーケンスを持っており、シーケンス内の各リクエストは前のリクエストの結果に依存する可能性があります。
ヒント:次のコードのいくつかのタイプの命名方法は少し奇妙に見えるかもしれませんが、それらのほとんどはアプリケーション専用の用語集に基づいて命名されています(例:Operation)。
public typealias PreviousRequestTuple = (
request: PushRequest,
response: NSURLResponse,
responseBody: JsonValue?
)
/// この操作をサーバーと同期させるために必要なプッシュリクエストのシーケンス。
/// このシーケンスのリクエストが完了するとすぐに、
/// `PushSyncQueueManager`は次のリクエストのためにシーケンスをポーリングします。
/// `nextRequest`が`nil`を返すと、
/// このシーケンスは完了と見なされます。
public protocol OperationRequestSequence: class {
/// このメソッドが`nil`を返すと、全体の`OperationRequestSequence`
/// は完了と見なされます。
func nextRequest(previousRequest: PreviousRequestTuple?) throws -> PushRequest?
}
nextRequest:
メソッドを呼び出してリクエストシーケンスがリクエストを生成する際、私たちは前のリクエストへの参照を提供します。これにはNSURLResponse
と JSON レスポンスボディ(存在する場合)が含まれます。各リクエストの結果は、次のリクエスト時に生成される可能性があります(PushRequest
オブジェクトが返されます)。次のリクエストがない場合(nil
が返される)や、リクエスト中に何らかの理由で必要な応答が返されなかった場合(この場合、リクエストシーケンスはthrows
します)。
注意すべき点は、PushRequest はこの返り値のタイプの理想的な名前ではないということです。このタイプはリクエストの詳細(終了コード、HTTP メソッドなど)を記述するだけで、実質的な作業には関与しません。これは宣言型設計において非常に重要な側面です。
あなたはおそらくこのプロトコルが特定のclass
に依存していることに気づいたでしょう。私たちは、OperationRequestSequence
が状態記述タイプであることを認識しているため、これを行いました。これは、前のリクエストから生成された結果をキャプチャし使用できる必要があります(たとえば、3 番目のリクエストでは最初のリクエストの応答結果を取得する必要があるかもしれません)。このアプローチはmutating
メソッドの構造を参考にしており、この部分に関するアップロード操作のコードがより複雑になっているようです(したがって、構造体を再割り当てすることはそれほど簡単なことではありません)。
OperationRequestSequence
プロトコルに基づいて最初のリクエストシーケンスを実装した後、nextRequest
メソッドを実装するよりも、リクエストチェーンを保存するために単純に配列を提供する方が適切であることがわかりました。そこで、リクエスト配列の実装を提供するためにArrayRequestSequence
プロトコルを追加しました:
public typealias RequestContinuation = (previous: PreviousRequestTuple?) throws -> PushRequest?
public protocol ArrayRequestSequence: OperationRequestSequence {
var currentRequestIndex: Int { get set }
var requests: [RequestContinuation] { get }
}
extension ArrayRequestSequence {
public func nextRequest(previous: PreviousRequestTuple?) throws -> PushRequest? {
let nextRequest = try self.requests[self.currentRequestIndex](previous: previous)
self.currentRequestIndex += 1
return nextRequest
}
}
この時点で、私たちは新しいアップロードシーケンスを定義しました。これは非常に小さな作業です。
リクエストシーケンスプロトコルの実装#
小さな例として、スナップショットをアップロードするためのアップロードシーケンスを見てみましょう(PlanGrid では、スナップショットは画像に描画されたエクスポート可能な青写真や注釈を指します):
/// スナップショットをアップロードするためのリクエストのシーケンスを記述します。
final class SnapshotUploadRequestSequence: ArrayRequestSequence {
// ボイラープレートの初期化子と
// インスタンス変数の定義コードを削除...
// これはリクエストシーケンスの定義です
lazy var requests: [RequestContinuation] = {
return [
// 1\. APIからAWSアップロードパッケージを取得
self._allocationRequest,
// 2\. スナップショットをAWSにアップロード
self._awsUploadRequest,
// 3\. APIでアップロードを確認
self._metadataRequest
]
}()
// 各リクエストの詳細な定義に続きます:
func _allocationRequest(previous: PreviousRequestTuple?) throws -> PushRequest? {
// このファイルアップロードのためのAPIリクエストを生成
// リクエストボディにJSON形式でファイルサイズを渡す
return PushInMemoryRequestDescription(
relativeURL: ApiEndpoints.snapshotAllocation(self.affectedModelUid.value),
httpMethod: .POST,
jsonBody: JsonValue(values:
[
"filesize" : self.imageUploadDescription.fullFileSize
]
),
operationId: self.operationId,
affectedModelUid: self.affectedModelUid,
requestIdentifier: SnapshotUploadRequestSequence.allocationRequest
)
}
func _awsUploadRequest(previous: PreviousRequestTuple?) throws -> PushRequest? {
// レスポンスボディにAWS割り当てデータが存在するか確認
guard let allocationData = previous?.responseBody else {
throw ImageCreationOperationError.MissingAllocationData
}
// AWS割り当てデータを解析しようとする
self.snapshotAllocationData = try AWSAllocationPackage(json: allocationData["snapshot"])
guard let snapshotAllocationData = self.snapshotAllocationData else {
throw ImageCreationOperationError.MissingAllocationData
}
// このスナップショットのファイルシステムパスを取得
let thumbImageFilePath = NSURL(fileURLWithPath:
SnapshotModel.pathForUid(
self.imageUploadDescription.modelUid,
size: .Full
)
)
// 画像をAWSにアップロードするmultipart/form-dataリクエストを生成
return AWSMultiPartRequestDescription(
targetURL: snapshotAllocationData.targetUrl,
httpMethod: .POST,
fileURL: thumbImageFilePath,
filename: snapshotAllocationData.filename,
operationId: self.operationId,
affectedModelUid: self.affectedModelUid,
requestIdentifier: SnapshotUploadRequestSequence.snapshotAWS,
formParameters: snapshotAllocationData.fields
)
}
func _metadataRequest(previous: PreviousRequestTuple?) throws -> PushRequest? {
// 完了したアップロードを確認するためのAPIリクエストを生成
return PushInMemoryRequestDescription(
relativeURL: ApiEndpoints.snapshotAllocation(self.affectedModelUid.value),
httpMethod: .PUT,
jsonBody: self.snapshotMetadata,
operationId: self.operationId,
affectedModelUid: self.affectedModelUid,
requestIdentifier: SnapshotUploadRequestSequence.metadataRequest
)
}
}
実装の過程で注意すべき点は以下の通りです:
- ここにはほとんど命令型コードがありません。ほとんどのコードはインスタンス変数と前回のリクエストの結果を通じてネットワークリクエストを記述しています。
- コードはネットワーク層を呼び出さず、アップロード操作のタイプ情報もありません。それらは各リクエストの詳細を記述するだけです。実際、このコードには観測可能な副作用はなく、内部状態を変更するだけです。
- このコードにはエラーハンドリングコードがほとんどありません。このタイプは、リクエストシーケンス内で発生する特定のエラー(例えば、前回のリクエストが結果を返さなかったなど)を処理するだけです。他のエラーは通常ネットワーク層で処理されます。
- 私たちは
PushInMemoryRequestDescription
/AWSMultipartRequestDescription
を使用して、自分たちの API サーバーまたは AWS サーバーにリクエストを送信する行動を抽象化しています。私たちのアップロードコードは、状況に応じてこれらの間で切り替え、異なる URL セッション設定を使用して、自分たちの API サーバーの認証情報を AWS に送信しないようにします。
私は全体のコードについて詳しく議論するつもりはありませんが、この例が私が以前に述べた宣言型設計方法の一連の利点を十分に示すことを願っています:
- 関心の分離: 上記で書かれたタイプは、一連のリクエストを記述するという単一の機能だけを持っています。
- 重複コードの削減: 上記で書かれたタイプには、リクエストを記述するコードのみが含まれており、ネットワークリクエストやエラーハンドリングのコードは含まれていません。
- 優れた API 設計: このような API 設計は、開発者の負担を軽減し、彼らは前のリクエストの結果に基づいて後続のリクエストが生成されることを保証するために、単純なプロトコルを実装するだけで済みます。
- 良好な可読性: 再度申し上げますが、上記のコードは非常に集中しています。私たちは、ボイラープレートコードの海の中で意図を見つける必要はなく、コードの意図を理解するためには、私たちの抽象方法について一定の理解が必要です。
今、もしNSOperationQueue
を使って私たちのアプローチを置き換えたらどうなるか考えてみてください。
NSOperationQueue
とは?#
NSOperationQueue
を使用するアプローチは非常に複雑になるため、この記事でそのコードを提供することはあまり良い選択ではありません。しかし、このアプローチについて議論することはできます。
関心の分離はこのアプローチでは実現が難しいです。リクエストシーケンスを単純に抽象化するのとは異なり、NSOperationQueue
内のNSOperations
オブジェクトはネットワークリクエストのスイッチ操作を担当します。ここにはリクエストのキャンセルやエラーハンドリングなどの機能が含まれています。異なる場所に似たようなアップロードコードが存在し、これらのコードは再利用が非常に難しいです。ほとんどのアップロードリクエストがNSOperation
に抽象化されている場合、サブクラスを使用することは良い選択ではありませんが、私たちのアップロードリクエストキューはNSOperationQueue
で装飾されたNSOperation
として抽象化されています。
NSOperationQueue
には無関係な情報が非常に多いです。コードのあちこちにネットワーク層の操作や、NSOperation
内の特定のメソッド(例えば、main
やfinish
メソッド)を呼び出すコードが見られます。具体的な API 呼び出しルールを深く理解する前に、具体的な操作が何をするためのものであるかを知るのは難しいです。
この API の処理方法は、ある意味で開発者の開発体験を悪化させます。単純な実装に対応するプロトコルとは異なり、Swift で上記の開発方法を採用すると、人々はいくつかの慣習的な規則を理解する必要がありますが、これらの規則は必ずしも遵守する必要はありません。
この処理方法は、開発者の負担を大幅に増加させます。単純なプロトコルを実装するのとは異なり、新しいバージョンの Swift でこのようなコードを実装する場合、特有の慣習を理解する必要があります。多くの記録された慣習は、プログラミングとは関係のないものです。
他の理由により、この API はネットワークリクエストのエラー報告に関連するバグを引き起こす可能性があります。各リクエスト操作が独自のエラーレポートコードを実行しないようにするために、これを 1 つの場所に集中させて処理します。エラーハンドリングコードはリクエストが終了した後に実行されます。その後、コードはリクエストタイプ内の error プロパティの値が存在するかどうかを確認します。エラーメッセージをタイムリーにフィードバックするために、開発者は操作が完了する前にNSOperation
内の error プロパティの値を設定する必要があります。これは強制的な規則ではないため、多くの新しいコードがそのプロパティの値を設定し忘れ、結果として多くのエラーメッセージが失われる可能性があります。
したがって、私たちは、私たちが紹介したこの新しい方法が、今後開発者がアップロードやその他の機能のコードを書くのに役立つことを期待しています。
まとめ#
宣言型プログラミング手法は、私たちのプログラミングスキルと開発効率に大きな影響を与えています。私たちは制限された API を提供しており、この API は単一の目的を持ち、奇妙なバグを残すことはありません。私たちはサブクラスやポリモーフィズムなどの手段を使用するのを避け、ジェネリックタイプに基づく宣言型スタイルのコードを使用してそれを置き換えることができます。私たちは美しいコードを書くことができます。私たちが書いたコードはテストが非常に簡単に行えます(この点について、プログラミング愛好者は宣言型スタイルのコードにおいてテストが必要ないと感じるかもしれません)。だからあなたは尋ねるかもしれません:「これが完璧無欠のプログラミング手法だとは言わないでください?」
まず第一に、具体的な抽象化プロセスでは、私たちはいくらかの時間と労力を費やすかもしれません。しかし、この費用は API を慎重に設計し、機能を実現するためにいくつかのテストを提供することで、使用者に参考を提供することによって代替できます。
次に、宣言型プログラミングは、あらゆる時期やビジネスに適しているわけではないことに注意してください。宣言型プログラミングを適用するには、コードベースに少なくとも同様の方法で複数回解決された問題がある必要があります。高度にカスタマイズ可能なアプリケーションで宣言型プログラミングを使用しようとし、全体のコードを誤って抽象化すると、最終的には乱雑な半宣言型コードが得られます。あらゆる抽象化プロセスにおいて、早すぎる抽象化は一連の理解しにくい問題を引き起こす可能性があります。
宣言型 API は、API の使用者にかかる圧力を API の開発者に移転しますが、命令型 API にはその必要はありません。優れた宣言型 API を提供するために、API の開発者はインターフェースの使用とインターフェースの実装の詳細を厳密に分離する必要があります。しかし、この要求を厳密に遵守する API は非常に少ないです。React や GraphQL は、宣言型 API がチームのコーディング体験を効果的に向上させることを証明しています。
実際、これは始まりに過ぎないと思います。私たちは複雑なライブラリに隠された複雑な詳細と、外部に提供されるシンプルで使いやすいインターフェースを徐々に発見することになるでしょう。いつの日か、私たちが宣言型プログラミングに基づく UI ライブラリを使用して iOS アプリケーションを構築できることを期待しています。