Как работать со множественными запросами. Композиция, Reducer, ФП

Привет, Хабр. Меня зовут Максим, я iOS-разработчик в компании FINCH. Сегодня я покажу вам некоторые практики использования функционального программирования, которые мы наработали у себя в отделе.

Сразу хочу отметить, что я не призываю вас повсеместно использовать функциональное программирование — это не панацея от всех проблем. Но мне кажется, в некоторых случаях, ФП может дать наиболее гибкие и элегантные решения нестандартных задач.

ФП – популярная концепция, поэтому я не буду объяснять основы. Уверен, что вы и так применяете map, reduce, compactMap, first(where:) и подобные технологии в своих проектах. В статье речь пойдет о решении проблемы множественных запросов и работе с reducer.

Проблема множественных запросов


Я работаю в аутсорс-продакшне, и бывают ситуации, когда клиент со своими субподрядчиками берет на себя создание бэкенда. Это далеко не самый удобный бэкенд и приходится делать множественные и параллельные запросы.

Иногда я мог написать что-то вроде:

networkClient.sendRequest(request1) { result in
            
    switch result {
          
    case .success(let response1):
                // ...
        self.networkClient.sendRequest(request2) { result in
                    // ...
             switch result {
                    
             case .success(let response2):
                   // ... что - то делаем со вторым response 
                   self.networkClient.sendRequest(request3) { result in
                            
                       switch result {
                       case .success(let response3):
                           // ... тут что-то делаем с конечным результатом
                           completion(Result.success(response3))
                            
                       case .failure(let error):
                           completion(Result.failure(.description(error)))
                       }

                   }

             case .failure(let error):
                 completionHandler(Result.failure(.description(error)))
             }

         }

    case .failure(let error):
        completionHandler(Result.failure(.description(error)))
    }
}

Отвратительно, правда? Но это та реальность с которой мне нужно было работать.

Мне нужно было отправить три последовательных запроса для авторизации. Во время рефакторинга я подумал, что хорошей идеей будет разбить каждый запрос на отдельные методы и вызвать их внутри completion, разгрузив, тем самым, один огромный метод. Получилось что-то вроде:


func obtainUserStatus(completion: @escaping (Result<AuthResponse>) -> Void) {
        
        let endpoint= AuthEndpoint.loginRoute
        
        networkService.request(endpoint: endpoint, cachingEnabled: false) { [weak self] (result: Result<LoginRouteResponse>) in
            
            switch result {
                
            case .success(let response):
                self?.obtainLoginResponse(response: response, completion: completion)

            case .failure(let error):
                completion(.failure(error))
                
            }
        }
        
    }

    private func obtainLoginResponse(_ response: LoginRouteResponse, completion: @escaping (Result<AuthResponse>) -> Void) {
        
       let endpoint= AuthEndpoint.login
        
        networkService.request(endpoint: endpoint, cachingEnabled: false) { [weak self] (result: Result<LoginResponse>) in
            
            switch result {
                
            case .success(let response):
                self?.obtainAuthResponse(response: response, completion: completion)

            case .failure(let error):
                completion(.failure(error))
                
            }
        }

private func obtainAuthResponse(_ response: LoginResponse, completion: @escaping (Result<AuthResponse>) -> Void) {
        
       let endpoint= AuthEndpoint.auth
        
        networkService.request(endpoint: endpoint, cachingEnabled: false) { (result: Result<AuthResponse>) in
            
    	completion(result)
        }

}

Видно, что в каждом из приватных методов мне приходится проксировать

completion: @escaping (Result<AuthResponse>) -> Void 

и это мне не очень нравится.

Тогда же мне в голову пришла мысль — «а почему бы не прибегнуть к функциональному программированию?» К тому же swift, с его магией и синтаксическим сахаром, позволяет интересно и удобоваримо разбивать код на отдельные элементы.

Композиция и Reducer


Функциональное программирование тесно связано с концепцией композиции – смешиванием, соединением чего-либо. В функциональном программировании композиция предполагает, что мы комбинируем поведение из отдельных блоков, а затем, в дальнейшем, работаем с ним.

Композиция с математической точки зрения — это что-то вроде:

func compose<A,B,C>(_ f: @escaping (A) -> B, and g: @escaping (B) -> C) -> (A) -> C {
    return { a in g(f(a)) }
}

Есть функции f и g, которые внутри себя задают выходные и входные параметры. Мы хотим получить какое-то результирующее поведение от этих входных методов.

Как пример, можно сделать два closure, один из которых увеличивает входное число на 1, а второй перемножает на само себя.

let increment: (Int) -> Int = { value in
    return value + 1
}
 
let multiply: (Int) -> Int = { value in
    return value * value
}

В результате мы хотим применить обе эти операции:


let result = compose(multiply, and: increment)
 
result(10) // в результате имеем число 101


К сожалению мой пример не является ассоциативным
( если мы поменяем местами increment и multiply, то получим число 121), но пока опустим этот момент.


let result = compose(increment, and: multiply)
 
result(10) // в результате имеем число 121

P.S. Я специально стараюсь сделать мои примеры более простыми, чтобы было максимально понятно)

На практике же часто нужно сделать что-то вроде этого:


let value: Int? = array
        
        .lazy
        .filter { $0 % 2 == 1 }
        .first(where: { $0 > 10 })

Это и есть композиция. Мы задаём входное воздействие и получаем некоторое выходное воздействие. Но это не просто сложение каких-то объектов – это сложение целого поведения.

А теперь подумаем более абстрактно :)


В нашем приложении у нас есть какое-то состояние (state). Это может быть экран который в данный момент видит пользователь или текущие данные, которые хранятся в приложении и тд.
Помимо этого у нас есть action — это то действие, которое может сделать пользователь (нажать на кнопку, скрольнуть коллекцию, закрыть приложение и тд). В результате мы оперируем этими двумя понятиями и связываем их друг с другом, то есть комбинируем, хммм комбинируем (где-то я уже это слышал).

А что если создать сущность, которая как раз скомбинирует мой state и action вместе?

Так мы получим Reducer


struct Reducer<S, A> {
    let reduce: (S, A) -> S
}

На вход метода reduce мы будем давать текущий state и action, а на выходе получим новый state, который образовался внутри reduce.

Описать эту структуру мы можем несколькими способами: задав новый state, используя функциональный метод или используя мутабельные модели.


struct Reducer<S, A> {
    let reduce: (S, A) -> S
}

struct Reducer<S, A> {
    let reduce: (S) -> (A) -> S
}

struct Reducer<S, A> {
    let reduce: (inout S, A) -> Void
}

Первый вариант «классический».

Второй – более функциональный. Смысл заключается в том, что мы возвращаем не state, а метод, который принимает action, который уже в свою очередь возвращает state. По сути это каррирование метода reduce.

Третий вариант – работа со state по ссылке. При таком подходе, мы не просто выдаем state, а работаем с ссылкой на объект который приходит на вход. Мне кажется, что этот способ не очень хорош, потому что подобные (мутабельные) модельки – это плохо. Лучше пересобирать новый state(instance) и возвращать его. Но для простоты и демонстрации дальнейших примеров, условимся использовать последний вариант.

Применение reducer


Применим концепцию Reducer на существующий код – создадим RequestState, затем инициализируем его, и зададим.


class RequestState {

    // MARK: - Private properties
    
    private let semaphore = DispatchSemaphore(value: 0)
    private let networkClient: NetworkClient = NetworkClientImp()
    
    // MARK: - Public methods
    
    func sendRequest<Response: Codable>(_ request: RequestProtocol, completion: ((Result<Response>) -> Void)?) {
        
        networkClient.sendRequest(request) { (result: Result<Response>) in
          
            completion?(result)   
            self.semaphore.signal()
         
        }
        
       semaphore.wait()
     
    }
    
}

Для синхронности запросов я добавил DispatchSemaphore

Идем дальше. Теперь нам нужно создать RequestAction с, допустим, тремя запросами.


enum RequestAction {
    
    case sendFirstRequest(FirstRequest)
    case sendSecondRequest(SecondRequest)
    case sendThirdRequest(ThirdRequest)
    
}

Теперь создаем Reducer, у которого есть RequestState и RequestAction. Задаем поведение – что мы хотим делать при первом, втором, третьем запросе.


let requestReducer = Reducer<RequestState, RequestAction> { state, action in
  
    switch action {
        
    case .sendFirstRequest(let request):
      
        state.sendRequest(request) { (result: Result<FirstResponse>) in
            
            // 1 Response
            
        }
      
    case .sendSecondRequest(let request):
        
        state.sendRequest(request) { (result: Result<SecondResponse>) in
            
            // 2 Response
            
        }
     
    case .sendThirdRequest(let request):
        
        state.sendRequest(request) { (result: Result<ThirdResponse>) in
            
            // 3 Response
            
        }
        
    }
    
}

В конце – вызываем эти методы. Получается более декларативный стиль, при котором видно, что идет первый, второй и третий запросы. Все читаемо и наглядно.


var state = RequestState()

requestReducer.reduce(&state, .sendFirstRequest(FirstRequest()))
requestReducer.reduce(&state, .sendSecondRequest(SecondRequest()))
requestReducer.reduce(&state, .sendThirdRequest(ThirdRequest()))

Вывод


Не бойтесь изучать новое и не бойтесь изучать функциональное программирование. Я думаю, что самые лучшие практики находятся на стыке технологий. Старайтесь комбинировать и брать лучше из разных парадигм программирования.

Если стоит какая-то нетривиальная задача, то есть смысл посмотреть на неё с другого угла.
Поделиться публикацией

Комментарии 14

    0
    В данной задаче Future больше подходит
      +1
      Поддерживаю. Фьючесы, промисы и рх отлично подходят для таких задач. А сейчас с iOS 13 прям из коробки библиотека Combine.
        0
        Вообще я не считаю правильным сразу тянуть либу для решения подобных задач, однако если у вас в проекте много нетривиальных кейсов, где постоянно происходит изменение данных и это все влияет на ui, то тут уже можно подключить rx, promise и тд, а так всегда лучше решать проблему нативно без сторонних либ. Просто данный пример скорее имеет иллюстративный характер
          +1
          никогда не понимал людей с фобией на сторонние библиотеки. Неужели лучше изобретать велосипед, причем преподносить это как что-то новое?

          А еще создать асинхронный поток и вызвать в нем по порядку все запросы синхронно — это слишком просто? Гораздо лучше наплодить структур/классов/перечислений, при этом толком не объяснив их предназначение
            0
            Библиотеки и велосипеды бывают разные, есть либы, которые перестают поддерживаться и в которых могут быть баги, а можно написать свою легковесную обертку и еще тестами покрыть
              0
              Просто подход, используемый в статье, не масштабируется. Если будет экран на котором нужно использовать три разных независимых backend API, создавать на каждый из них RequestState будет проблематично, так как в единый момент времени будем много замороженных потоков. Если же шарить общий RequestState, то запросы будут последовательными, а так как вызываемые API являются совершенно разными, то хотелось бы их обрабатывать параллельно. Все эти проблемы уже решены сообществом в опенсорсных библиотеках.
            +1
            Не обязательно тянуть библиотеку. Реализация минимального функционала промисов занимает пару часов времени.
        +2
        У вас метод sendRequest является блокирующим, наверное лучше ему дать имя sendSyncRequest. Да и вызывающий тред будет заблокан на время запроса, думаю ему можно найти более нужное применение чем ждать ответа от сервера. Вы реализовали своего рода корутины, но очень не оптимально по ресурсам.
          +2

          Кажется, ваш reducer — это просто функтор. А сама идея — обычный конечный автомат, он же FSM.

            0
            Это так, но идея статьи скорее в том, что иногда можно прибегать ко всяким функциональным штукам, чтобы решить какую-нибудь задачу более красиво и элегантно)
            0

            А как ваши коллеги из FINCH отнеслись к этому подходу? Это общие наработки компании или личный вклад?

              0
              Это скорее личный вклад, мы даже провели локальный митап, где я показал свои наработки. В целом коллегам понравилось, но мы решили, что еще поработаем на архитектурой и абстракциями в функциональном стиле. Вообще на youtube есть неплохой канал Functional Swift и там много интересных уроков, где чуваки переосмысляют некоторые привычные понятия в функциональном стиле, очень советую)
              0
              Спасибо за интересную статью! Книжка «Эффект Медичи» как раз про такой подход в философском смысле)
                0
                Ну не знаю, автор конечно молодец, но я бы использовал уже готовые решения c promise. В целом для чтива не плохой материал, почему бы и нет.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое