- 原文作者: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 はネットワークリクエストのエラー報告に関連するバグを引き起こす可能性があります。各リクエスト操作が独自のエラーレポートコードを実行しないようにするために、私たちはそれを一箇所に集中させて処理します。エラーハンドリングコードはリクエストが終了した後に実行されます。その後、コードはリクエストタイプのエラー属性の値が存在するかどうかを確認します。エラーメッセージをタイムリーにフィードバックするために、開発者は操作が完了する前にNSOperation
内のエラー属性の値を設定する必要があります。これは強制的な規則ではないため、多くの新しいコードがその属性の値を設定するのを忘れ、エラーメッセージが失われる可能性があります。
したがって、私たちは、私たちが紹介したこの新しい方法が、将来的に開発者がアップロードやその他の機能のコードを書くのを助けることを期待しています。
まとめ#
宣言型プログラミングの方法は、私たちのプログラミングスキルと開発効率に大きな影響を与えています。私たちは制限された API を提供しており、この API は単一の目的を持ち、奇妙なバグを残しません。私たちはサブクラスやポリモーフィズムなどの手段を使用するのを避け、代わりにジェネリックタイプに基づく宣言型スタイルのコードを使用してそれを置き換えます。私たちは美しいコードを書くことができます。私たちが書いたコードは、テストが非常に簡単に行えるものです(この点について、プログラミング愛好者は、宣言型スタイルのコードではテストが必要ないと感じるかもしれません)。したがって、あなたは「これが完璧無欠なプログラミング方法だとは言わないでください」と尋ねるかもしれません。
まず第一に、具体的な抽象化プロセスでは、私たちはいくつかの時間と労力を費やすかもしれません。しかし、この費用は、API を慎重に設計し、いくつかのテストを提供することで、ユースケースを実現し、使用者に参考を提供することで相殺できます。
次に、宣言型プログラミングは、あらゆる時期やビジネスに適しているわけではないことに注意してください。宣言型プログラミングを適用するには、コードベースに少なくとも 1 つの類似の方法で複数回解決された問題が存在する必要があります。高度にカスタマイズ可能なアプリケーションで宣言型プログラミングを使用しようとし、全体のコードを誤って抽象化した場合、最終的には乱雑な半宣言型コードを得ることになります。あらゆる抽象化プロセスにおいて、早すぎる抽象化は一連の理解しにくい問題を引き起こします。
宣言型 API は、API の使用者にかかる負担を API 開発者に移転しますが、命令型 API ではその必要はありません。優れた宣言型 API を提供するために、API 開発者はインターフェースの使用とインターフェースの実装の詳細を厳密に分離する必要があります。しかし、この要求を厳密に遵守する API は非常に少ないです。React や GraphQL は、宣言型 API がチームのコーディング体験を効果的に向上させることを証明しています。
実際、私はこれが始まりに過ぎないと考えています。私たちは、複雑なライブラリに隠された複雑な詳細と、外部に提供されるシンプルで使いやすいインターフェースを徐々に発見することになるでしょう。いつの日か、宣言型プログラミングに基づく UI ライブラリを使用して私たちの iOS アプリケーションを構築できることを期待しています。