iOSアプリケーションでクライアント/サーバー相互作用ロジックを分離する方法
公開: 2022-03-11現在、ほとんどのモバイルアプリケーションは、クライアントとサーバーの相互作用に大きく依存しています。 これは、重いタスクのほとんどをバックエンドサーバーにオフロードできることを意味するだけでなく、これらのモバイルアプリケーションがインターネット経由でのみ利用できるあらゆる種類の機能を提供できるようにします。
バックエンドサーバーは通常、RESTfulAPIを介してサービスを提供するように設計されています。 より単純なアプリケーションの場合、スパゲッティコードを作成して取得したくなることがよくあります。 APIを呼び出すコードを残りのアプリケーションロジックと混合します。 ただし、アプリケーションが複雑になり、ますます多くのAPIを処理するようになると、構造化されていない、計画外の方法でこれらのAPIと対話することが厄介になる可能性があります。
この記事では、iOSアプリケーション用のクリーンなRESTクライアントネットワークモジュールを構築するためのアーキテクチャアプローチについて説明します。これにより、すべてのクライアント/サーバー相互作用ロジックを残りのアプリケーションコードから分離できます。
クライアントサーバーアプリケーション
典型的なクライアント/サーバーの相互作用は次のようになります。
- ユーザーが何らかのアクションを実行します(たとえば、ボタンをタップしたり、画面上で他のジェスチャを実行したりします)。
- アプリケーションは、ユーザーのアクションに応じてHTTP/RESTリクエストを準備して送信します。
- サーバーは要求を処理し、それに応じてアプリケーションに応答します。
- アプリケーションは応答を受信し、それに基づいてユーザーインターフェイスを更新します。
一見、全体的なプロセスは単純に見えるかもしれませんが、詳細について考える必要があります。
バックエンドサーバーAPIがアドバタイズされたとおりに機能すると仮定しても(常にそうであるとは限りません)、設計が不十分な場合が多く、非効率的であるか、使用が困難ですらあります。 一般的な煩わしさの1つは、APIへのすべての呼び出しで、呼び出し元が同じ情報を冗長に提供する必要があることです(たとえば、要求データのフォーマット方法、サーバーが現在サインインしているユーザーを識別するために使用できるアクセストークンなど)。
モバイルアプリケーションでは、さまざまな目的で複数のバックエンドサーバーを同時に利用する必要がある場合もあります。 たとえば、1つのサーバーがユーザー認証専用で、別のサーバーが分析の収集のみを処理する場合があります。
さらに、一般的なRESTクライアントは、リモートAPIを呼び出す以上のことを行う必要があります。 保留中のリクエストをキャンセルする機能、またはエラーを処理するためのクリーンで管理しやすいアプローチは、堅牢なモバイルアプリケーションに組み込む必要のある機能の例です。
アーキテクチャの概要
RESTクライアントのコアは、次のコンポーネントに基づいて構築されます。
- モデル:バックエンドサーバーとの間で送受信されるデータの構造を反映して、アプリケーションのデータモデルを説明するクラス。
- パーサー:サーバー応答のデコードとモデルオブジェクトの生成を担当します。
- エラー:誤ったサーバー応答を表すオブジェクト。
- クライアント:バックエンドサーバーに要求を送信し、応答を受信します。
- サービス:論理的にリンクされた操作(認証、ユーザー関連データの管理、分析など)を管理します。
これは、これらの各コンポーネントが相互に作用する方法です。
上の画像の矢印1から10は、サービスを呼び出すアプリケーションと、最終的に要求されたデータをモデルオブジェクトとして返すサービスとの間の理想的な一連の操作を示しています。 そのフローの各コンポーネントには、モジュール内の関心の分離を保証する特定の役割があります。
実装
現在ログインしているユーザーの友達のリストをロードする架空のソーシャルネットワークアプリケーションの一部として、RESTクライアントを実装します。 リモートサーバーが応答にJSONを使用すると仮定します。
モデルとパーサーを実装することから始めましょう。
生のJSONからモデルオブジェクトへ
最初のモデルであるUser
は、ソーシャルネットワークのすべてのユーザーの情報の構造を定義します。 簡単にするために、このチュートリアルに絶対に必要なフィールドのみを含めます(実際のアプリケーションでは、構造には通常、より多くのプロパティがあります)。
struct User { var id: String var email: String? var name: String? }
APIを介してバックエンドサーバーからすべてのユーザーデータを受信するため、API応答を有効なUser
オブジェクトに解析する方法が必要です。 これを行うには、解析されたJSONオブジェクト( Dictionary
)をパラメーターとして受け入れるコンストラクターをUser
に追加します。 JSONオブジェクトをエイリアスタイプとして定義します。
typealias JSON = [String: Any]
次に、次のようにコンストラクター関数をUser
構造体に追加します。
extension User { init?(json: JSON) { guard let id = json["id"] as? String else { return nil } self.id = id self.email = json["email"] as? String self.name = json["name"] as? String } }
User
の元のデフォルトコンストラクターを保持するために、 User
タイプの拡張機能を介してコンストラクターを追加します。
次に、生のAPI応答からUser
オブジェクトを作成するには、次の2つの手順を実行する必要があります。
// Transform raw JSON data to parsed JSON object using JSONSerializer (part of standard library) let userObject = (try? JSONSerialization.jsonObject(with: data, options: [])) as? JSON // Create an instance of `User` structure from parsed JSON object let user = userObject.flatMap(User.init)
合理化されたエラー処理
バックエンドサーバーとの対話を試みるときに発生する可能性のあるさまざまなエラーを表すタイプを定義します。 このようなエラーはすべて、次の3つの基本的なカテゴリに分類できます。
- インターネットに接続できません
- 応答の一部として報告されたエラー(検証エラー、不十分なアクセス権など)
- サーバーが応答の一部として報告できないエラー(サーバーのクラッシュ、応答のタイムアウトなど)
エラーオブジェクトを列挙型として定義できます。 そして、その間、 ServiceError
タイプをError
プロトコルに準拠させることをお勧めします。 これにより、Swiftが提供する標準メカニズム( throw
を使用してエラーをスローするなど)を使用して、これらのエラー値を使用および処理できるようになります。
enum ServiceError: Error { case noInternetConnection case custom(String) case other }
noInternetConnection
やother
エラーとは異なり、カスタムエラーには値が関連付けられています。 これにより、サーバーからのエラー応答をエラー自体の関連値として使用できるようになり、エラーにより多くのコンテキストが与えられます。
次に、 errorDescription
プロパティをServiceError
列挙に追加して、エラーをよりわかりやすくします。 noInternetConnection
およびother
エラーのハードコードされたメッセージを追加し、関連する値をcustom
エラーのメッセージとして使用します。
extension ServiceError: LocalizedError { var errorDescription: String? { switch self { case .noInternetConnection: return "No Internet connection" case .other: return "Something went wrong" case .custom(let message): return message } } }
ServiceError
列挙に実装する必要があるものがもう1つあります。 custom
エラーの場合、サーバーのJSONデータをエラーオブジェクトに変換する必要があります。 これを行うには、モデルの場合に使用したのと同じアプローチを使用します。
extension ServiceError { init(json: JSON) { if let message = json["message"] as? String { self = .custom(message) } else { self = .other } } }
アプリケーションとバックエンドサーバー間のギャップを埋める
クライアントコンポーネントは、アプリケーションとバックエンドサーバーの間の仲介役になります。 これは、アプリケーションとサーバーが通信する方法を定義する重要なコンポーネントですが、データモデルとその構造については何も知りません。 クライアントは、提供されたパラメーターを使用して特定のURLを呼び出し、JSONオブジェクトとして解析された着信JSONデータを返す責任があります。
enum RequestMethod: String { case get = "GET" case post = "POST" case put = "PUT" case delete = "DELETE" } final class WebClient { private var baseUrl: String init(baseUrl: String) { self.baseUrl = baseUrl } func load(path: String, method: RequestMethod, params: JSON, completion: @escaping (Any?, ServiceError?) -> ()) -> URLSessionDataTask? { // TODO: Add implementation } }
上記のコードで何が起こっているのかを調べてみましょう…
最初に、4つの一般的なHTTPメソッドを記述する列挙型RequestMethod
を宣言しました。 これらは、RESTAPIで使用されるメソッドの1つです。
WebClient
クラスには、受信したすべての相対URLを解決するために使用されるbaseURL
プロパティが含まれています。 アプリケーションが複数のサーバーと対話する必要がある場合は、 baseURL
の値がそれぞれ異なるWebClient
の複数のインスタンスを作成できます。
クライアントには単一のメソッドload
があり、パラメーター、リクエストメソッド、リクエストパラメータ、および完了クロージャとしてbaseURL
に相対的なパスを取ります。 完了クロージャは、解析されたJSONとServiceError
をパラメーターとして使用して呼び出されます。 今のところ、上記の方法には実装がありません。これについては後ほど説明します。

load
メソッドを実装する前に、メソッドで利用可能なすべての情報からURL
を作成する方法が必要です。 この目的のためにURL
クラスを拡張します。
extension URL { init(baseUrl: String, path: String, params: JSON, method: RequestMethod) { var components = URLComponents(string: baseUrl)! components.path += path switch method { case .get, .delete: components.queryItems = params.map { URLQueryItem(name: $0.key, value: String(describing: $0.value)) } default: break } self = components.url! } }
ここでは、ベースURLへのパスを追加するだけです。 GETおよびDELETEHTTPメソッドの場合、クエリパラメータもURL文字列に追加します。
次に、指定されたパラメーターからURLRequest
のインスタンスを作成できる必要があります。 これを行うには、 URL
に対して行ったのと同様のことを行います。
extension URLRequest { init(baseUrl: String, path: String, method: RequestMethod, params: JSON) { let url = URL(baseUrl: baseUrl, path: path, params: params, method: method) self.init(url: url) httpMethod = method.rawValue setValue("application/json", forHTTPHeaderField: "Accept") setValue("application/json", forHTTPHeaderField: "Content-Type") switch method { case .post, .put: httpBody = try! JSONSerialization.data(withJSONObject: params, options: []) default: break } } }
ここでは、最初に拡張機能のコンストラクターを使用してURL
を作成します。 次に、このURL
でURLRequest
のインスタンスを初期化し、必要に応じていくつかのHTTPヘッダーを設定します。次に、POSTまたはPUT HTTPメソッドの場合は、リクエストの本文にパラメーターを追加します。
すべての前提条件をカバーしたので、 load
メソッドを実装できます。
final class WebClient { private var baseUrl: String init(baseUrl: String) { self.baseUrl = baseUrl } func load(path: String, method: RequestMethod, params: JSON, completion: @escaping (Any?, ServiceError?) -> ()) -> URLSessionDataTask? { // Checking internet connection availability if !Reachability.isConnectedToNetwork() { completion(nil, ServiceError.noInternetConnection) return nil } // Adding common parameters var parameters = params if let token = KeychainWrapper.itemForKey("application_token") { parameters["token"] = token } // Creating the URLRequest object let request = URLRequest(baseUrl: baseUrl, path: path, method: method, params: params) // Sending request to the server. let task = URLSession.shared.dataTask(with: request) { data, response, error in // Parsing incoming data var object: Any? = nil if let data = data { object = try? JSONSerialization.jsonObject(with: data, options: []) } if let httpResponse = response as? HTTPURLResponse, (200..<300) ~= httpResponse.statusCode { completion(object, nil) } else { let error = (object as? JSON).flatMap(ServiceError.init) ?? ServiceError.other completion(nil, error) } } task.resume() return task } }
上記のload
メソッドは、次の手順を実行します。
- インターネット接続の可用性を確認してください。 インターネット接続が利用できない場合は、パラメーターとして
noInternetConnection
エラーを使用して、完了クロージャーをすぐに呼び出します。 (注:コードのReachability
はカスタムクラスであり、インターネット接続をチェックするための一般的なアプローチの1つを使用します。) - 共通パラメータを追加します。 。 これには、アプリケーショントークンやユーザーIDなどの一般的なパラメーターを含めることができます。
- 拡張機能のコンストラクターを使用して、
URLRequest
オブジェクトを作成します。 - サーバーにリクエストを送信します。
URLSession
オブジェクトを使用して、サーバーにデータを送信します。 - 受信データを解析します。 サーバーが応答すると、最初に
JSONSerialization
を使用して応答ペイロードをJSONオブジェクトに解析します。 次に、応答のステータスコードを確認します。 それが成功コードである場合(つまり、200から299の範囲)、JSONオブジェクトを使用して完了クロージャーを呼び出します。 それ以外の場合は、JSONオブジェクトをServiceError
オブジェクトに変換し、そのエラーオブジェクトを使用して完了クロージャーを呼び出します。
論理的にリンクされた操作のためのサービスの定義
私たちのアプリケーションの場合、ユーザーの友達に関連するタスクを処理するサービスが必要です。 このために、 FriendsService
クラスを作成します。 理想的には、このようなクラスは、友達のリストの取得、新しい友達の追加、友達の削除、友達のカテゴリへのグループ化などの操作を担当します。このチュートリアルでは、簡単にするために1つのメソッドのみを実装します。 :
final class FriendsService { private let client = WebClient(baseUrl: "https://your_server_host/api/v1") @discardableResult func loadFriends(forUser user: User, completion: @escaping ([User]?, ServiceError?) -> ()) -> URLSessionDataTask? { let params: JSON = ["user_id": user.id] return client.load(path: "/friends", method: .get, params: params) { result, error in let dictionaries = result as? [JSON] completion(dictionaries?.flatMap(User.init), error) } } }
FriendsService
クラスには、 WebClient
タイプのclient
プロパティが含まれています。 友達の管理を担当するリモートサーバーのベースURLで初期化されます。 前述のように、他のサービスクラスでは、必要に応じて、 WebClient
の別のインスタンスを別のURLで初期化できます。
1つのサーバーのみで動作するアプリケーションの場合、 WebClient
クラスには、そのサーバーのURLで初期化するコンストラクターを指定できます。
final class WebClient { // ... init() { self.baseUrl = "https://your_server_base_url" } // ... }
loadFriends
メソッドは、呼び出されると、必要なすべてのパラメーターを準備し、 FriendService
のWebClient
のインスタンスを使用してAPIリクエストを作成します。 WebClient
を介してサーバーから応答を受信した後、JSONオブジェクトをUser
モデルに変換し、それらをパラメーターとして使用して完了クロージャーを呼び出します。
FriendService
の一般的な使用法は、次のようになります。
let friendsTask: URLSessionDataTask! let activityIndicator: UIActivityIndicatorView! var friends: [User] = [] func friendsButtonTapped() { friendsTask?.cancel() //Cancel previous loading task. activityIndicator.startAnimating() //Show loading indicator friendsTask = FriendsService().loadFriends(forUser: currentUser) {[weak self] friends, error in DispatchQueue.main.async { self?.activityIndicator.stopAnimating() //Stop loading indicators if let error = error { print(error.localizedDescription) //Handle service error } else if let friends = friends { self?.friends = friends //Update friends property self?.updateUI() //Update user interface } } } }
上記の例では、ユーザーがネットワーク内の友達のリストを表示するためのボタンをタップするたびに、関数friendsButtonTapped
が呼び出されると想定しています。 また、 friendsTask
プロパティにタスクへの参照を保持し、 friendsTask?.cancel()
を呼び出していつでもリクエストをキャンセルできるようにします。
これにより、保留中のリクエストのライフサイクルをより細かく制御できるようになり、リクエストが無関係になったと判断したときにリクエストを終了できるようになります。
結論
この記事では、iOSアプリケーション用のネットワークモジュールのシンプルなアーキテクチャを共有しました。これは、実装が簡単で、ほとんどのiOSアプリケーションの複雑なネットワークニーズに適合させることができます。 ただし、これからの重要なポイントは、適切に設計されたRESTクライアントとそれに付随するコンポーネント(アプリケーションロジックの残りの部分から分離されている)が、アプリケーション自体がますます複雑になった場合でも、アプリケーションのクライアント/サーバー相互作用コードを単純に保つのに役立つことです。 。
この記事が次のiOSアプリケーションの構築に役立つことを願っています。 このネットワークモジュールのソースコードはGitHubにあります。 コードをチェックして、フォークして、変更して、遊んでください。
あなたとあなたのプロジェクトにとってより好ましい他のアーキテクチャを見つけた場合は、以下のコメントセクションで詳細を共有してください。