Наше приложение переживает редизайн и добавление новых фич очень даже быстро, во многом, благодаря моему решению несколько недель назад внедрить многомодульность.
Как родилась идея разбить приложение?
Каналы про разработку, на которые я подписан, постоянно публиковали статьи про многомодульность. Мы со знакомыми постоянно обсуждали эту идею. Все вокруг пестрило ей в моем инфополе, но я противился этой мысли.
Во-первых, я не считал что на приложение, в котором на тот момент было 4-5 экранов нэтив и вебвью, необходимы модули. Во-вторых, не понимал какие модули выделить я смогу. В-третьих, боялся взять задачу и не сделать ее.
Но потом я понял - нужно это делать прямо сейчас, или потом будет очень сложно. Я решил, что не хочу разбивать обросшее сложной логикой взаимодействия приложение, а сделаю это сейчас, пока это не так сложно.
Какие модули я решил выделить?
Сетевой слой (Про него сегодня хочется поговорить)
Слой работы с данными на клиенте (Будет во второй части)
Модуль с экраном для тестировщиков (Будет в третьей части)
Сетевой слой
Он у меня реализован с помощью нативного URLSession. Я взял уже не помню откуда идею простейшего сетевого взаимодействия.
В слой я вынес абсолютно все файлы, которые хоть как то связаны с сетью. То есть, вынес реализацию запросов на сервер, реализацию отправки параметров в JS у вебвью, а так же работу с Firebase. Весь модуль я покрыт unit тестами, но сразу сделал их, чтобы можно было проверить и работу с бекендом. Перейдем к реализации.
У меня есть базовый билдер АПИ:
protocol APIBuilder {
var urlRequest: URLRequest { get }
var baseUrl: URL { get }
var path: String { get }
}
А есть конкретные реализации:
enum ModelsAPI {
case getModelsPerPage(Int)
case getModelByIds(Int)
}
extension ModelsAPI: APIBuilder {
var urlRequest: URLRequest {
switch self {
case .getModelsPerPage(let page):
var components = URLComponents(string: baseUrl.appendingPathComponent(path).absoluteString)
components?.queryItems = [
URLQueryItem(name: "page", value: "\(page)")
]
guard let url = components?.url else { return URLRequest(url: baseUrl.appendingPathComponent(path)) }
var request = URLRequest(url: url)
request.httpMethod = "POST"
return request
case .getModelByIds(let id):
var request = URLRequest(url: baseUrl.appendingPathComponent(path).appendingPathComponent("\(id)"))
request.httpMethod = "GET"
return request
}
}
var path: String {
return "api/models"
}
}
При этом есть и ошибки, которые сетевой слой возвращает, в зависимости от ситуации:
/// Custom errors of NetworkLayer
public enum APIError: Error {
case decodingError
case errorCode(Int)
case unknown
}
extension APIError: LocalizedError {
public var errorDescription: String? {
switch self {
case .decodingError:
return "APIError: decodingError"
case .errorCode(let code):
return "APIError: \(code)"
case .unknown:
return "APIError: unknown"
}
}
}
Сервис, который обрабатывает запрос:
final class NetworkService {
func request<T: Codable>(from endpoint: APIBuilder) -> AnyPublisher<T, APIError> {
return ApiManager
.sharedInstance
.dataTaskPublisher(for: endpoint.urlRequest)
.receive(on: DispatchQueue.main)
.mapError { error in
print("error on", error.failingURL)
return APIError.unknown
}
.flatMap { data, response -> AnyPublisher<T, APIError> in
guard let response = response as? HTTPURLResponse else {
return Fail(error: APIError.unknown).eraseToAnyPublisher()
}
if (200...299).contains(response.statusCode) {
let jsonDecoder = JSONDecoder()
return Just(data)
.decode(type: T.self, decoder: jsonDecoder)
.mapError { _ in APIError.decodingError}
.eraseToAnyPublisher()
} else {
return Fail(error: APIError.errorCode(response.statusCode)).eraseToAnyPublisher()
}
}
.eraseToAnyPublisher()
}
}
А также реализация ModelsNetworkService:
/// Service to network work of models
public class ModelsNetworkService {
public init() {}
fileprivate lazy var networkService = NetworkService()
fileprivate var loadModelByIdResponse: ResponseModel?
fileprivate var modelsResponse: ModelsResponseModel?
fileprivate var cancellables = Set<AnyCancellable>()
/// Method to get single model by id
/// - Parameters:
/// - modelId: Model id from backend
/// - completion: It return's single model or APIError
public func loadModel(byId modelId: Int, completion: @escaping (model?, APIError?) -> Void ) {
let cancellable = networkService.request(from: ModelsAPI.getModelByIds(modelId))
.sink { [weak self] res in
guard let strongSelf = self else { return }
switch res {
case .finished:
guard let model = strongSelf.loadModelByIdResponse!.data else {
return
}
completion(restaurant, nil)
case .failure(let error):
print("loadModel byIds: \(error.errorDescription)")
completion(nil, error)
}
} receiveValue: { [weak self] response in
self?.loadModelByIdResponse = response
}
cancellables.insert(cancellable)
}
/// Method to get ModelsResponse by location
/// - Parameters:
/// - page: Current page to pagination
/// - completion: It return's single ModelsResponse or APIError
public func loadModels(page: Int, completion: @escaping (ModelsResponse?, APIError?) -> Void ) {
let cancellable = networkService
.request(from: ModelsAPI.getModelsPerPage(page))
.sink { [weak self] res in
guard let strongSelf = self else { return }
switch res {
case .finished:
completion(strongSelf.modelsResponse, nil)
case .failure(let error):
completion(nil, error)
}
} receiveValue: { [weak self] response in
self?.modelsResponse = response
}
cancellables.insert(cancellable)
}
}
Все это я поместил в SPM
import PackageDescription
let package = Package(
name: "NetworkLayer",
platforms: [.iOS(.v13), .macOS(.v10_12)],
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "NetworkLayer",
targets: ["NetworkLayer"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
.package(name: "Firebase", url: "https://github.com/firebase/firebase-ios-sdk.git", from: "7.0.0")
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "NetworkLayer",
dependencies: [
// The product name you need. In this example, FirebaseAuth.
.product(name: "FirebaseAnalytics", package: "Firebase"),
.product(name: "FirebaseCrashlytics", package: "Firebase")
], path: "Sources"
),
.testTarget(
name: "NetworkLayerTests",
dependencies: ["NetworkLayer"]),
]
)
Теперь же сам тесты:
func testLoadRestaurants() {
var modelsMain: [Model]? = nil
let expectation = XCTestExpectation.init(description: "testLoadModels")
modelsNetworkService.loadModels(page: 1) { [weak self] response, error in
if let response = response {
modelsMain = response.data
expectation.fulfill()
} else {
XCTFail("Fail")
}
}
wait(for: [expectation], timeout: 30.0)
print(modelsMain?.count)
XCTAssertTrue(modelsMain?.count ?? 0 > 0)
}
Я, также, подключаю firebase, но взаимодействие с ним не вижу смысла показывать.
Этот слой я подключаю к основному проекту и использую таким образом:
import NetworkLayer
...
fileprivate lazy var modelsNetworkService = ModelsNetworkService()
...
modelsNetworkService.loadModels(page: page) { [weak self] response, error in
guard let strongSelf = self else { return }
if let response = response {
strongSelf.output.loadedModels(modelsResponse: response, meta: response.meta)
} else if let error = error {
strongSelf.output.loadingModelsError(error: error.localizedDescription)
}
}
Стоит еще о кое чем рассказать. Раньше была путаница с моделями: какая в сетевой слой, какая во внутренний слой обработки данных. Теперь я вынес модели связанные с сетью в этот же модуль и путаница ушла.
Заключение первой части
В данный момент я выделил от проекта все три модуля и могу сказать, что скорость билда в firebase уменьшилось с 15-20 минут до 3-4 минут максимум. За этим очевидным плюсом скрывается еще то, что архитектура проекта стала более правильной и понятной.
Я стараюсь поддерживать в слоях SOLID и благодаря этому изменения конкретного слоя не влияют на другие слои и основное приложение в фатальном плане.
Надеюсь статья вышла интересной :-) Буду рад критике, и предложениям по улучшению как реализации, так и рассказа.
Комментарии (4)
Prostor9
18.11.2021 12:48А почему Вы в loadModels в completion-блоке не используете Result?
И Вы используете Combine? Не слежу просто за реактивными фреймворками.Еще guard let strongSelf = self else { return } можно много где повыкидывать, т.к. все равно с nil работаете и выдаете
chesnikovofficial Автор
18.11.2021 12:50С result начал знакомится, благодаря вашему комментарию, спасибо, удобная вещь. Обязательно нужно будет завезти.
Guard наверное остаточное у меня от прошлых мест работы где говорили его пихать везде где только можно. Надо будет при рефакторинге критично посмотреть на свой код.
0xFEE1DE4D
modelsNetworkService.loadModels(page: 1)
тесты которые лезут по настоящему в сеть не очень надежны, и не особо приветствуется. лучше замокать networkService, и проверить что при замоканном failure происходит вызов кложуры с верной ошибкой, а при замоканом успехе response не пустой, и в нем есть данныеи кажется urlRequest в ModelsAPI немного громоздкий получается, возможно стоит абстрагироваться до method и parameters, и уже в абстракции делать URLRequest
chesnikovofficial Автор
Спасибо за критику, действительно стоит абстрагироваться. Тоже думаю уже над этим.
По поводу тестов тоже понял, не совсем корректное место для проверки работы сервисов продукта выбрал.