Jak wyizolować logikę interakcji klient-serwer w aplikacjach na iOS
Opublikowany: 2022-03-11Obecnie większość aplikacji mobilnych w dużym stopniu opiera się na interakcjach klient-serwer. Oznacza to nie tylko, że mogą przenieść większość swoich ciężkich zadań na serwery zaplecza, ale także umożliwia aplikacjom mobilnym oferowanie wszelkiego rodzaju funkcji i funkcjonalności, które można udostępnić tylko przez Internet.
Serwery zaplecza są zwykle zaprojektowane tak, aby oferować swoje usługi za pośrednictwem interfejsów API RESTful. W przypadku prostszych aplikacji często kusi nas tworzenie kodu spaghetti; mieszanie kodu wywołującego API z resztą logiki aplikacji. Jednak w miarę jak aplikacje stają się coraz bardziej złożone i obsługują coraz więcej interfejsów API, interakcja z tymi interfejsami API w sposób nieustrukturyzowany i nieplanowany może być uciążliwa.
W tym artykule omówiono podejście architektoniczne do tworzenia czystego modułu sieciowego klienta REST dla aplikacji systemu iOS, który umożliwia odizolowanie całej logiki interakcji klient-serwer od reszty kodu aplikacji.
Aplikacje klient-serwer
Typowa interakcja klient-serwer wygląda mniej więcej tak:
- Użytkownik wykonuje jakąś czynność (np. stuka w jakiś przycisk lub wykonuje inny gest na ekranie).
- Aplikacja przygotowuje i wysyła żądanie HTTP/REST w odpowiedzi na akcję użytkownika.
- Serwer przetwarza żądanie i odpowiednio odpowiada na zgłoszenie.
- Aplikacja otrzymuje odpowiedź i na jej podstawie aktualizuje interfejs użytkownika.
Na pierwszy rzut oka cały proces może wydawać się prosty, ale musimy pomyśleć o szczegółach.
Nawet zakładając, że interfejs API serwera zaplecza działa zgodnie z reklamą (co nie zawsze tak jest!), często może być źle zaprojektowany, co czyni go nieefektywnym lub nawet trudnym w użyciu. Powszechną irytacją jest to, że wszystkie wywołania interfejsu API wymagają od wywołującego nadmiarowego podania tych samych informacji (np. sposobu formatowania danych żądania, tokenu dostępu, którego serwer może użyć do zidentyfikowania aktualnie zalogowanego użytkownika itd.).
Aplikacje mobilne mogą również wymagać jednoczesnego korzystania z wielu serwerów zaplecza do różnych celów. Jeden serwer może na przykład być dedykowany do uwierzytelniania użytkowników, podczas gdy inny zajmuje się wyłącznie gromadzeniem danych analitycznych.
Co więcej, typowy klient REST będzie musiał robić znacznie więcej niż tylko wywoływać zdalne interfejsy API. Możliwość anulowania oczekujących żądań lub czyste i łatwe w zarządzaniu podejście do obsługi błędów to przykłady funkcji, które należy wbudować w każdą solidną aplikację mobilną.
Przegląd architektury
Rdzeń naszego klienta REST zostanie zbudowany na następujących komponentach:
- Modele: Klasy opisujące modele danych naszej aplikacji, odzwierciedlające strukturę danych otrzymanych z lub wysłanych do serwerów zaplecza.
- Parsery: Odpowiedzialne za dekodowanie odpowiedzi serwera i tworzenie obiektów modelu.
- Błędy: Obiekty reprezentujące błędne odpowiedzi serwera.
- Klient: Wysyła żądania do serwerów zaplecza i odbiera odpowiedzi.
- Usługi: Zarządzaj logicznie powiązanymi operacjami (np. uwierzytelnianie, zarządzanie danymi użytkownika, analizy itp.).
W ten sposób każdy z tych elementów będzie ze sobą współdziałać:
Strzałki od 1 do 10 na powyższym obrazku pokazują idealną sekwencję operacji między aplikacją wywołującą usługę a usługą ostatecznie zwracającą żądane dane jako obiekt modelu. Każdy składnik w tym przepływie ma określoną rolę, zapewniając oddzielenie problemów w module.
Realizacja
Wdrożymy naszego klienta REST w ramach naszej wyimaginowanej aplikacji sieci społecznościowej, do której załadujemy listę aktualnie zalogowanych znajomych użytkownika. Założymy, że nasz zdalny serwer używa JSON do odpowiedzi.
Zacznijmy od implementacji naszych modeli i parserów.
Od surowego JSON do obiektów modelowych
Nasz pierwszy model, User
, definiuje strukturę informacji dla każdego użytkownika sieci społecznościowej. Aby uprościć sprawę, uwzględnimy tylko pola, które są absolutnie niezbędne w tym samouczku (w rzeczywistej aplikacji struktura zazwyczaj miałaby znacznie więcej właściwości).
struct User { var id: String var email: String? var name: String? }
Ponieważ otrzymamy wszystkie dane użytkownika z serwera zaplecza za pośrednictwem jego API, potrzebujemy sposobu na przetworzenie odpowiedzi API na prawidłowy obiekt User
. W tym celu dodamy do User
konstruktor akceptujący przeanalizowany obiekt JSON ( Dictionary
) jako parametr. Zdefiniujemy nasz obiekt JSON jako typ z aliasem:
typealias JSON = [String: Any]
Następnie dodamy funkcję konstruktora do naszej struktury User
w następujący sposób:
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 } }
Aby zachować oryginalny konstruktor domyślny User
, dodajemy konstruktor poprzez rozszerzenie typu User
.
Następnie, aby utworzyć obiekt User
z nieprzetworzonej odpowiedzi API, musimy wykonać następujące dwa kroki:
// 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)
Usprawniona obsługa błędów
Zdefiniujemy typ reprezentujący różne błędy, które mogą wystąpić podczas próby interakcji z serwerami zaplecza. Wszystkie takie błędy możemy podzielić na trzy podstawowe kategorie:
- Brak połączenia z Internetem
- Błędy zgłoszone w ramach odpowiedzi (np. błędy walidacji, niewystarczające prawa dostępu itp.)
- Błędy, których serwer nie zgłasza w ramach odpowiedzi (np. awaria serwera, przekroczenie limitu czasu odpowiedzi itp.)
Możemy zdefiniować nasze obiekty błędów jako typ wyliczeniowy. A skoro już przy tym jesteśmy, dobrym pomysłem jest, aby nasz typ ServiceError
był zgodny z protokołem Error
. Umożliwi nam to wykorzystanie i obsługę tych wartości błędów przy użyciu standardowych mechanizmów udostępnianych przez Swift (takich jak użycie throw
do rzucenia błędu).
enum ServiceError: Error { case noInternetConnection case custom(String) case other }
W przeciwieństwie do noInternetConnection
i other
błędów, błąd niestandardowy ma powiązaną z nim wartość. Umożliwi nam to użycie odpowiedzi na błąd z serwera jako wartości skojarzonej z samym błędem, co nada błędowi więcej kontekstu.
Teraz dodajmy właściwość errorDescription
do wyliczenia ServiceError
, aby błędy były bardziej opisowe. Dodamy zakodowane komunikaty dla noInternetConnection
i other
błędów i użyjemy powiązanej wartości jako komunikatu dla błędów 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 } } }
Jest jeszcze jedna rzecz, którą musimy zaimplementować w naszym wyliczeniu ServiceError
. W przypadku błędu custom
musimy przekształcić dane JSON serwera w obiekt błędu. W tym celu stosujemy to samo podejście, które zastosowaliśmy w przypadku modeli:
extension ServiceError { init(json: JSON) { if let message = json["message"] as? String { self = .custom(message) } else { self = .other } } }
Wypełnianie luki między aplikacją a serwerem zaplecza
Komponent klienta będzie pośrednikiem między aplikacją a serwerem zaplecza. Jest to kluczowy komponent, który określi sposób komunikacji aplikacji i serwera, ale nie będzie wiedział nic o modelach danych i ich strukturach. Klient będzie odpowiedzialny za wywoływanie określonych adresów URL z podanymi parametrami i zwracanie przychodzących danych JSON przetworzonych jako obiekty 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 } }
Przyjrzyjmy się, co dzieje się w powyższym kodzie…
Najpierw zadeklarowaliśmy typ wyliczenia, RequestMethod
, który opisuje cztery popularne metody HTTP. Są to jedne z metod używanych w interfejsach API REST.

Klasa WebClient
zawiera właściwość baseURL
, która będzie używana do rozpoznawania wszystkich względnych adresów URL, które otrzymuje. W przypadku, gdy nasza aplikacja musi współpracować z wieloma serwerami, możemy utworzyć wiele instancji WebClient
, z których każda ma inną wartość dla baseURL
.
Klient ma pojedynczą metodę load
, która przyjmuje ścieżkę względną baseURL
jako parametr, metodę żądania, parametry żądania i zamknięcie zakończenia. Zamknięcie zakończenia jest wywoływane z parametrami przeanalizowanego kodu JSON i ServiceError
. Na razie w powyższej metodzie brakuje implementacji, do której wkrótce przejdziemy.
Przed zaimplementowaniem metody load
potrzebujemy sposobu na utworzenie URL
ze wszystkich informacji dostępnych dla metody. W tym celu rozszerzymy klasę 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! } }
Tutaj po prostu dodajemy ścieżkę do podstawowego adresu URL. W przypadku metod GET i DELETE HTTP dodajemy również parametry zapytania do ciągu adresu URL.
Następnie musimy mieć możliwość tworzenia instancji URLRequest
z podanych parametrów. Aby to zrobić, zrobimy coś podobnego do tego, co zrobiliśmy dla 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 } } }
Tutaj najpierw tworzymy URL
za pomocą konstruktora z rozszerzenia. Następnie inicjujemy instancję URLRequest
tym URL
, ustawiamy kilka nagłówków HTTP według potrzeb, a następnie w przypadku metod POST lub PUT HTTP dodajemy parametry do treści żądania.
Teraz, gdy omówiliśmy już wszystkie wymagania wstępne, możemy zaimplementować metodę 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 } }
Powyższa metoda load
wykonuje następujące kroki:
- Sprawdź dostępność połączenia internetowego. Jeśli połączenie internetowe nie jest dostępne, natychmiast wywołujemy zamknięcie zakończenia z błędem
noInternetConnection
jako parametrem. (Uwaga:Reachability
w kodzie to klasa niestandardowa, która wykorzystuje jedno z typowych podejść do sprawdzania połączenia internetowego.) - Dodaj wspólne parametry. . Może to obejmować typowe parametry, takie jak token aplikacji lub identyfikator użytkownika.
- Utwórz obiekt
URLRequest
, używając konstruktora z rozszerzenia. - Wyślij żądanie do serwera. Używamy obiektu
URLSession
do wysyłania danych na serwer. - Analizuj dane przychodzące. Gdy serwer odpowiada, najpierw analizujemy ładunek odpowiedzi do obiektu JSON przy użyciu
JSONSerialization
. Następnie sprawdzamy kod statusu odpowiedzi. Jeśli jest to kod sukcesu (tj. w zakresie od 200 do 299), wywołujemy zamknięcie zakończenia z obiektem JSON. W przeciwnym razie przekształcamy obiekt JSON w obiektServiceError
i wywołujemy zamknięcie zakończenia z tym obiektem błędu.
Definiowanie usług dla operacji połączonych logicznie
W przypadku naszej aplikacji potrzebujemy usługi, która zajmie się zadaniami związanymi ze znajomymi użytkownika. W tym celu tworzymy klasę FriendsService
. Idealnie, klasa taka jak ta będzie odpowiedzialna za takie operacje, jak pobieranie listy znajomych, dodawanie nowego znajomego, usuwanie znajomego, grupowanie znajomych w kategorię itp. Dla uproszczenia w tym samouczku zaimplementujemy tylko jedną metodę :
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) } } }
Klasa FriendsService
zawiera właściwość client
typu WebClient
. Jest inicjowany za pomocą podstawowego adresu URL zdalnego serwera, który jest odpowiedzialny za zarządzanie przyjaciółmi. Jak wspomniano wcześniej, w innych klasach usług możemy mieć inną instancję WebClient
zainicjowaną z innym adresem URL, jeśli to konieczne.
W przypadku aplikacji, która działa tylko z jednym serwerem, klasie WebClient
można nadać konstruktor, który inicjuje się za pomocą adresu URL tego serwera:
final class WebClient { // ... init() { self.baseUrl = "https://your_server_base_url" } // ... }
Po wywołaniu metoda loadFriends
przygotowuje wszystkie niezbędne parametry i używa instancji WebClient
FriendService
do wysłania żądania API. Po odebraniu odpowiedzi z serwera za pośrednictwem WebClient
przekształca obiekt JSON w modele User
i wywołuje zamknięcie zakończenia z nimi jako parametrem.
Typowe użycie FriendService
może wyglądać mniej więcej tak:
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 } } } }
W powyższym przykładzie zakładamy, że funkcja friendsButtonTapped
jest wywoływana za każdym razem, gdy użytkownik naciśnie przycisk, który ma pokazać mu listę znajomych w sieci. Zachowujemy również odniesienie do zadania we właściwości friendsTask
, dzięki czemu możemy w dowolnym momencie anulować żądanie, wywołując friendsTask?.cancel()
.
Dzięki temu mamy większą kontrolę nad cyklem życia oczekujących żądań, co pozwala nam je zakończyć, gdy stwierdzimy, że stały się nieistotne.
Wniosek
W tym artykule podzieliłem się prostą architekturą modułu sieciowego dla aplikacji iOS, który jest zarówno trywialny do wdrożenia, jak i może być dostosowany do skomplikowanych potrzeb sieciowych większości aplikacji iOS. Kluczowym wnioskiem z tego jest jednak to, że właściwie zaprojektowany klient REST i towarzyszące mu komponenty — które są odizolowane od reszty logiki aplikacji — mogą pomóc w utrzymaniu prostego kodu interakcji klient-serwer aplikacji, nawet gdy sama aplikacja staje się coraz bardziej złożona. .
Mam nadzieję, że ten artykuł okaże się pomocny przy tworzeniu kolejnej aplikacji na iOS. Kod źródłowy tego modułu sieciowego można znaleźć w serwisie GitHub. Sprawdź kod, rozwidlej go, zmień go, baw się nim.
Jeśli znajdziesz inną architekturę, która jest bardziej korzystna dla Ciebie i Twojego projektu, udostępnij szczegóły w sekcji komentarzy poniżej.