Как мы суслика яблоками кормили или эффективный backend на Go для iOS

в 8:47, , рубрики: api, backend, Go, golang, iOS, разработка мобильных приложений, разработка под iOS

Как мы суслика яблоками кормили или эффективный backend на Go для iOS - 1

Как и обещал, рассказываю о том, как мы мигрировали свой бэкенд на Go и смогли уменьшить объем бизнес логики на клиенте более, чем на треть.

Для кого: небольшим компаниям, Go и мобильным разработчикам, а также всем, кто в тренде или просто интересуется данной тематикой.
О чем: причины перехода на Go, с какими сложностями столкнулись, а также инструкции и советы по улучшению архитектуры мобильного приложения и его бэкенда.
Уровень: junior и middle.

Долгое время наша команда мобильной аутсорс разработки работала над сторонними проектами, у которых были свои бэкенд разработчики, а мы выступали в роли подрядчика для конкретного продукта. Несмотря на то, что в договоренностях всегда было четко оговорено, что именно мы, как мобильные девелоперы, диктуем музыку и API, далеко не всегда это помогало.

Как мы суслика яблоками кормили или эффективный backend на Go для iOS - 2

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

Так получилось, что у нас тогда в команде был довольно сильный Java(Spring) разработчик, и мы решили каждому новому заказчику твердо объявлять: мы сами пишем бэкенд, либо ищите кого-нибудь другого. Поначалу боялись, что такая принципиальная позиция будет отпугивать, а мы в итоге останемся голыми на хлебе и воде. Но как оказалось, что если мы уже понравились кому-то на этапе переговоров и с нами хотят работать, то практически обо всем можно договориться. Даже когда у клиента в команде уже есть свои люди, которых он изначально планировал задействовать. Тогда-то мы узнали такое умное слово как микросервисы, и что можно делать отдельный сервак с бизнес логикой, выполняющий задачи строго для мобильного приложения. Не буду спорить, что такой подход не везде уместен, но речь дальше пойдет не об этом.

Причины перехода на Go

После нескольких успешных проектов Java оказалась слишком тяжелой для нас. Много времени уходило на рутину, чтобы сделать все максимально удобно для приложения.
Не хочу сказать ничего плохого о Spring и Java в целом, это удивительный инструмент под серьезные задачи, как огромный толстопузый испанский галеон. А мы искали что-то больше похожее на легковесный пиратский клипер.
Нам надо было быстро внедрять фичи, легко их менять и не греть голову в поисках самого оптимального решения в каждой ситуации. Знаете, как это бывает, когда долго гуглишь на предмет типового решения твоей задачи, чтобы оно было наиболее подходящим, потом оказывается, что из 10 из них 5 уже устарели. А потом еще тратишь полчаса на выбор названия для переменной.

У Go такой проблемы нет. От слова совсем. Иногда даже через слишком: сидишь, ищешь идеальное решение, а StackOverflow тебе на это отвечает: 'Ну да, просто циклом for, а ты чего ждал?'
Со временем к этому привыкаешь и перестаешь гуглить всякие мелочи по пустякам, а начинаешь включать голову и просто писать код.

Какие возникли сложности

Начнем с того, что там нет наследования. Поначалу это просто выносило мозг. Приходится ломать все свое представление об ООП и привыкать к [утиной типизации].(https://ru.wikipedia.org/wiki/%D0%A3%D1%82%D0%B8%D0%BD%D0%B0%D1%8F_%D1%82%D0%B8%D0%BF%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D1%8F) Формулируя простыми словами: если это выглядит как утка, плавает как утка и крякает как утка, то это, возможно, и есть утка.
По сути, есть только интерфейсное наследование.

А во-вторых, из существенных минусов, — малое количество готовых инструментов, но зато много багов. Многие вещи нельзя сделать привычным способом, а кое-что вообще отсутствует как класс. Например, нет нормального фреймворка для IoC (инверсия зависимостей). Опытные гоферы скажут, что есть либа от Facebook. Но может я просто не умею ее готовить, либо все-таки ее удобство на самом деле оставляет желать лучшего. Оно просто не может сравниться со Spring и потому приходится много работать руками.

Еще из небольших ограничений Go в целом, например, нельзя сделать API вида:

/cards/:id
/cards/something

Так как для существующего http роутера — это взаимоисключающие запросы. Он путается между wildcard переменной и конкретным адресом something. Глупое ограничение, но приходится с этим жить. Если же кто-то знает решение, то буду рад услышать.

Также отсутствует hibernate или более менее адекватные аналоги. Да, есть множество ORM, но все они пока довольно слабые. Лучшее, что я встретил за время разработки на Go — это gorm. Ее главное преимущество — это удобнейший маппинг ответа от базы в структуру. А запросы придется писать на голом sql, если не хотите провести долгие часы за отладкой сомнительного поведения.

P.S. Хочу отдельно поделиться workaround-ом, который возник в процессе работы с этой либой. Если вам нужно записать id после insert в какую-то переменную с помощью gorm, а не в структуру как обычно, то поможет следующий костыль. На уровне запроса переименовываем результат returning на любой другой, отличный от id:

... returning id as value

С последующим сканом в переменную:

... Row().Scan(&variable)

Оказывается, дело в том, что поле 'id' воспринимается gorm-ом как конкретное поле объекта. И чтобы развязаться, нужно на уровне запроса ее переименовать во что-нибудь другое.

Плюсы или почему мы все-таки пишем на Go

Хочется начать с порога вхождения: он минимален. Вспоминая какой скрежет вызывал в освоении тот же Spring, то Go, по сравнению с ним, можно преподавать в младших классах, настолько он прост.

И эта простота заключается не только в языке, но и в окружении, которое он за собой несет.
Вам не нужно читать долгие маны по gradle и maven, не потребуется писать длиннющие конфиги, чтобы все хотя бы просто один раз запустилось. Здесь все обходится парой команд, а достойный сборщик и профилировщик уже является частью языка и не требует глубокого исследования для старта.
Как говорится: easy to learn, hard to master. Это то, чего мне всегда лично не хватало в современных технологиях: их как будто делают не для людей.

Из этого же следует скорость разработки. Язык был сделан для одной цели:
Как мы суслика яблоками кормили или эффективный backend на Go для iOS - 3

По сути, это backend язык для бизнеса. Он быстр, он прост и позволяет решать сложные задачи понятными способами. Насчет сложных задач и понятности — это отдельная тема для разговора, потому что в Go есть такая классная вещь как горутинки и каналы. Это удобнейшая многопоточность с минимальной возможностью для выстрела себе в ногу.

Архитектура

Web

В качестве web framework остановили свой выбор на Gin. Есть еще Revel, но нам он показался слишком узким и непоколебимо диктующим свою парадигму. Мы же предпочитаем чуть больше развязанные руки, чтобы можно было быть гибкими.

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

Конечно, и он не без проблем. Некоторые решения, например, кэш, сделаны третьей стороной. И происходит конфликт импортов, если вы у себя привыкли использовать import через github, а у них сделано через gopkg и наоборот. В итоге два плагина могут быть просто взаимоисключающими.
Если кто-то знает решение этой проблемы, то напишите, пожалуйста, в комментариях.

Менеджер зависимостей

Долго писать не буду, а сразу скажу, что это без сомнений Glide. Если вы работали с gradle или maven, то вам наверняка знакома парадигма объявлений зависимостей в неком файле с последующим их задействованием по необходимости. Так вот Glide — это хомячий Gradle, с решением конфликтов и прочими плюшками.

Кстати, если у вас возникнут проблемы при тестировании, когда go test лезет в папку vendor, жадно тестируя каждую либу, то проблема решается элементарно:

go test $(glide novendor)

Этот параметр исключает папку vendor из тестирования.
В сам репозиторий достаточно положить glide.yaml и glide.lock файлы.

Мобильной разработке это все никак не поможет, но просто, чтобы вы знали)

ORM и Realm

Это будет объемный раздел о передаче и хранении данных с бэкенда на клиент. Начнем с Go, плавно переходя на мобильную платформу.

А что такое Realm и чем он лучше CoreData/Массивов/SQLite?

Если вы никогда не сталкивались с Realm и не понимаете о чем речь, то правильно раскрыли спойлер.

Realm — это мобильная база, которая облегчает работу с синхронизацией данных на протяжении всего приложения. В ней нет таких проблем как в CoreData, где постоянно приходится работать в контекстах, даже когда объект еще никуда не сохранен. Легче соблюдать консистентность.
Достаточно просто создать сущность и работать с ней как с обычным объектом, передавая между потоками и жонглируя ею как душе угодно.

Множество операций она делает за вас, но, конечно, и у нее есть косяки: отсутствует не привязанный к регистру поиск, в целом поиск не доделан нормально, потребляет памяти как не в себя (особенно на android), отсутствует группировка как в FRC, и так далее.

Мы посчитали, что стоит мириться с этими проблемами и оно того стоит.

Чтобы не повторяться, коротко скажу, что мы в качестве orm используем Gorm и дам пару рекомендаций:

  • Пишите SQL запросы руками, не ленитесь. Заодно выучите SQL, если еще этого не сделали.
  • Старайтесь делать запросы в рамках одной транзакции, по возможности.
  • Выбирайте из базы минимум необходимых вам полей.
  • Обязательно маппите все в структуры. Чем меньше промежуточных переменных, тем лучше.

Наверное, это все применимо к любой технологии, тут я немного скапитанил, но все же. Лишний раз напомнить не повредит, это важно.

Теперь, что касается мобильного приложения. Ваша основная задача — сделать так, чтобы поля, возвращаемые в запросах, имели одинаковые названия с соответствующими им на клиенте. Этого можно легко добиться с помощью так называемых тегов:
Как мы суслика яблоками кормили или эффективный backend на Go для iOS - 4
Убедитесь, что тег json имеет правильное название. И желательно, чтобы у него был установлен флаг omitempty, как в примере. Это позволяет избежать захламления ответа пустующими полями.

Почему Go?

У вас может справедливо возникнуть вопрос: при чем тут вообще Go, если одинаковые названия можно сделать в любом языке? И будете правы, но одним из преимуществ Go является легчайшее форматирование чего угодно через рефлексию и структуры. Пусть рефлексия есть во многих языках, но в Go с ней работать проще всего.

Кстати, если вам нужно спрятать из ответа пустую структуру, то наилучший способ — это перегрузить метод MarshalJSON у структуры:

// Допустим, надо скрыть пустое поле Pharmacy у объекта Object
func (r Object) MarshalJSON() ([]byte, error) {
    type Alias Object
    var pharmacy *Pharmacy = nil
// Если id != 0, то используем значение. Если нет - ставим nil
    if r.Pharmacy.ID != 0 {
        pharmacy = &r.Pharmacy
    }
    return json.Marshal(&struct {
        Pharmacy           *Pharmacy `json:"pharmacy,omitempty"`
        Alias
    }{
        Pharmacy:           pharmacy,
        Alias:              (Alias)(r),
    })
}

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

Кроме названий полей еще обратите внимание на их типы. Числа должны быть числами, а строки строками (спасибо, кэп). В том, что касается дат, то удобнее всего использовать RFC3339. На сервере дату можно отформатировать также через перегрузку:

func (c *Comment) MarshalJSON() ([]byte, error) {
    type Alias Comment
    return json.Marshal(&struct {
        CreatedAt string `json:"createdAt"`
        *Alias
    }{
        CreatedAt: c.CreatedAt.Format(time.RFC3339),
        Alias:  (*Alias)(c),
    })
}

А на клиенте это делается через форматирование даты по следующему шаблону:

"yyyy-MM-dd'T'HH:mm:ssZ"

Еще одним преимуществом RFC3339 является то, что он выступает форматом даты по умолчанию для Swagger. И сама по себе отформатированная таким образом дата, довольно читаема для человека, особенно по сравнению с posix time.

На клиенте же (пример для iOS, но на Android аналогично), при идеальном совпадении названий всех полей и отношений класса, сохранение можно сделать одним генерик методом:

func save(dictionary: [String : AnyObject]) -> Promise<Void>{
    return Promise {fulfill, reject in
        let realm = Realm.instance
       // Если у вас есть дата в словаре, то здесь надо ее отформатировать перед записью.
       // Так как реалм не умеет сохранять дату в виде строго.
        try! realm.write {
            realm.create(T.self, value: dictionary, update: true)
        }
        fulfill()
    }
}

Для массивов ситуация аналогичная, но сохранение придется гонять уже в цикле. Часто неопытные разработчики допускают ошибку и заворачивают в цикл целиком блок записи:

array.forEach { object in
    try! realm.write {
        realm.create(T.self, value: object, update: true)
    }
}

Что просто в корне неверно, потому что так вы открываете новую транзакцию на каждый объект, вместо того, чтобы сохранить все скопом. А если у вас подключены еще уведомления для обновлений, то все становится еще 'веселее'. Правильнее сделать следующим образом, вынося транзакцию на уровень выше:

try! realm.write {
    array.forEach { object in
        realm.create(T.self, value: object, update: true)
    }
}

Как вы могли заметить, у нас полностью отвалился промежуточный слой, отвечающий за маппинг. Когда данные достаточно подготовлены, их можно сразу лупить в базу без дополнительной обработки. И чем лучше у вас бэкенд, тем меньше этой дополнительной обработки потребуется. В идеале только дату сконвертировать в объект. Все остальное должно быть сделано заранее.

Кстати, немного отходя от темы. Если вам не нужно иметь персистентную базу данных на клиенте, то это не повод отказываться от Realm. Он позволяет работать с собой строго в оперативной памяти, сбрасывая свое наполнение по первому требованию.
Ссылки для iOS и Android.
Такой подход позволяет использовать все преимущества реактивной базы данных и вышеописанный маппинг.

Еще хочу добавить для особо внимательных к мелочам: здесь нет утверждения, что Go — это единственно верное решение и панацея для мобильной разработки. Каждый может решать эту задачу по-своему. Мы выбрали этот путь.

Структура Go проекта

Сейчас пойдет много кода для Go разработчиков. Если вы мобильный девелопер, то можете свободно пролистать к следующему разделу.

Теперь мы подошли к самому интересному, если вы Go разработчик. Допустим, вы пишите бэкенд для некоего типового приложения: у вас есть слой с REST API, некая бизнес логика, модель, логика работы с базой данных, утилиты, скрипты миграции и конфиг с ресурсами. Вам как-то все это надо увязать у себя в проекте по классам и папочкам, соблюсти принципы SOLID и, желательно, не сойти с ума при этом.
Пока накидаем абстрактно, не погружаясь слишком глубоко, но так, чтобы была понятна общая структура. Если будет интересно, то посвящу этому полноценный отдельный материал. Все-таки сейчас речь идет о мобильном приложении в связке с Go.

Сразу оговорюсь, что не претендую на догматичность своих высказываний, каждый волен работать в своем проекте как считает нужным.

Начнем со скриншота нашей структуры:
Как мы суслика яблоками кормили или эффективный backend на Go для iOS - 5
(До чего милые в Intellij Idea хомячки, не правда ли? Каждый раз умиляюсь)
В неразвернутых директориях содержатся сразу Go файлы, либо файлы ресурсов. Проще говоря, все раскрыто так, чтобы видеть максимальное погружение.

В этой статьей расскажу только о том, что отвечает за бизнес логику: api, сервисы, работа с базой и как все это зависит друг от друга. Если вы, уважаемая публика, проявите интерес к этой теме, то потом распишу и остальное, потому как информации там слишком много для одной статьи.

Итак, по порядку:

Web
В вебе хранится все, что отвечает за обработку запросов: байндеры, фильтры и контроллеры — а вся их спайка происходит в api.go. Пример такого склеивания:

regions := r.Group("/regions")
regions.GET("/list", Cache.Gin, rc.List)
regions.GET("/list/active", Cache.Gin, regionController.ListActive)
regions.GET("", binders.Coordinates, regionController.RegionByCoord)

Там же происходит инициализация контроллеров и инъекция зависимостей. По сути весь api.go файл состоит из метода Run, где формируется и стартуется роутер, и кучи вспомогательных методов по созданию контроллеров со всеми зависимостями и их групп.

Web.Binders
В папке binders располагаются биндеры, которые парсят параметры из запросов, конвертируют в удобный формат и закидывают в контекст для дальнейшей работы.
Пример метода из этого пакета. Он берет параметр из query, конвертирует в bool и кладет в контекст:

func OpenNow(c *gin.Context)  {
    openNow, _ := strconv.ParseBool(c.Query(BindingOpenNow))
    c.Set(BindingOpenNow, openNow)
}

Самый простой вариант без обработки ошибок. Просто для наглядности.

Web.Controllers
Обычно на уровне контроллеров делают больше всего ошибок: напихают лишней логики, забудут про интерфейсы и изоляцию, а потом вообще скатятся к функциональному программированию. Вообще в Go контроллеры страдают от той же болезни, что и в iOS: их постоянно перенагружают. Поэтому сразу определим, какие задачи они должны выполнять:

  • получать параметры запроса;
  • вызывать соответствующий метод сервиса;
  • отправлять ответ об успехе или ошибке с изменением форматирования по необходимости.
    Необходимость — это, например, когда сервис логично возвращает числом id некоего объекта, нет ничего криминального в том, что контроллер обернет его в map перед отправкой:

    c.IndentedJSON(http.StatusCreated, gin.H { "identifier": m.ID })

Возьмем какой-нибудь пример типового контроллера.

Класс, если опускать импорты, начинается с интерфейса контроллера. Да-да, соблюдаем букву 'D' в слове SOLID, даже если у вас всегда будет только одна реализация. Это значительно облегчает тестирование, давая возможность подменять сам контроллер на его mock:

type Order interface {
    PlaceOrder(c *gin.Context)
    AroundWithPrices(c *gin.Context)
}

Далее у нас идет сама структура контроллера и его конструктор, принимающий в себя зависимости, который мы будем вызывать при создании контроллера в api.go:

// С маленькой буквы, чтобы наружу ничего не вываливалось
type order struct {
    service services.Order
}

func NewOrder(service services.Order) Order {
    return &order {
        service: service,
    }
}

И, наконец, метод, обрабатывающий запрос. Так как мы успешно прошли слой с биндингом, то можем быть уверены, что все параметры у нас гарантировано есть и мы можем получить их с помощью MustGet, не боясь панических атак:

func (o order)PlaceOrder(c *gin.Context) {
    m := c.MustGet(BindingOrder).(*model.Order)
    o.service.PlaceOrder(m)
    c.IndentedJSON(http.StatusCreated, gin.H {
        "identifier": m.ID,
    })
}

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

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

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

func NewOrder(repo repositories.Order, txFactory TransactionFactory) Order {
    return &order { repo: repo, txFactory: txFactory }
}

Фабрика транзакций — это просто класс, генерирующий транзакции, здесь ничего сложного:

type TransactionFactory interface {
    BeginNewTransaction() Transaction
}

Полный код фабрики для gorm

type TransactionFactory interface {
    BeginNewTransaction() Transaction
}

type transactionFactory struct {
    db *gorm.DB
}

func NewTransactionFactory(db *gorm.DB) TransactionFactory {
    return &transactionFactory{db: db}
}

func (t transactionFactory)BeginNewTransaction() Transaction {
    tx := new(transaction)
    tx.db = t.db
    tx.Begin()
    return tx
}

А вот на самих транзакциях остановиться стоит. Начнем с того, что это вообще такое. Транзакция представляет из себя тот же интерфейс с реализацией, который содержит методы для старта транзакции, завершения, отката и доступа к реализации движка уровнем ниже:

type Transaction interface {
    Begin()
    Commit()
    Rollback()
    DataSource() interface{}
}

Полный код транзакции для gorm

type Transaction interface {
    Begin()
    Commit()
    Rollback()
    DataSource() interface{}
}

type transaction struct {
    Transaction
    db *gorm.DB
    tx *gorm.DB
}

func (t *transaction)Begin() {
    t.tx = t.db.Begin()
}

func (t *transaction)Commit() {
    t.tx.Commit()
}

func (t *transaction)Rollback() {
    t.tx.Rollback()
}

func (t *transaction)DataSource() interface{} {
    return t.tx
}

Если с begin, commit, rollback все должно быть понятно, то Datasource — это просто костыль для доступа к низкоуровневой реализации, потому что работа с любой БД в Go устроена так, что транзакция является просто копией акссессора к базе со своими измененными настройками. Он нам понадобится позже при работе в репозиториях.

Собственно, вот и пример работы с транзакциями в методе сервиса:

func (o order)PlaceOrder(m *model.Order)  {
    tx := o.txFactory.BeginNewTransaction()
    defer tx.Commit()
    o.repo.Insert(tx, m)
}

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

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

Экспертам

Знаю, что нет управления уровнями изоляции.
Если нашли еще какие косяки — пишите в комментах.

В качестве дополнительного совета юниорам, хочу сказать, что транзакция должна быть открыта минимально возможное время. Постарайтесь подготовить все данные так, чтобы на период между begin и commit приходилось минимальное количество логики и вычислений.
Бывает, что транзакцию открывают и идут курить, отправляя, например запрос в гугл. А потом удивляются, почему это с дедлоком зафакапилось все.

Интересный факт
Во многих современных базах данных, deadlock определяется максимально просто: по таймауту. При большой нагрузке сканировать ресурсы на предмет определения блокировки — дорого. Поэтому часто вместо этого используется обычный таймаут. Например, в mysql. Если не знать эту особенность, то можно подарить себе чудеснейшие часы веселой отладки.

Repositories
Тоже самое: интерфейс, структура, конструктор, который, как правило, уже без параметров.
Просто приведу пример операции Insert, которую мы вызывали в коде сервиса:

func (order)Insert(tx Transaction, m *model.Order) {
    db := tx.DataSource().(*gorm.DB)
    query := "insert into orders (shop_id) values (?) returning id"
    db.Raw(query, m.Shop.ID).Scan(m)
}

Получили из транзакции низкоуровневый модификатор доступа, составили запрос, выполнили его. Готово.

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

Приложение

Ладно, суслики это мило, но как теперь с этим работать на клиенте?

Начнем, как и в случае с Go, с нашего стека. Вообще, мы активно используем реактив практически везде, но сейчас расскажу про более щадящий вариант архитектуры, чтобы так сразу не травмировать ничью психику.

Стек

Сетевой слой:
Alamofire для Swift проектов и AFNetworking для Objective-C.
Кстати, а вы знали, что Alamofire — это и есть AFNetworking? Префикс AF значит Alamofire, в чем можно убедиться, заглянув в лицензию AFNetworking:
Как мы суслика яблоками кормили или эффективный backend на Go для iOS - 6

Замыкания:
Многие в качестве callback-ов для бизнес логики передают в параметры запросов блоки для успеха/провала или лепят один на все. В итоге в параметрах каждого метода бизнес логики висит толстенное замыкание или даже не одно, что не сказывается положительно на читаемости проекта.
Иногда блок отдают в качестве возвращаемого значения, что тоже неудобно.

Есть такая замечательная вещь как промисы. Реализация iOS: PromiseKit. Простыми словами — вместо кучи блоков, передаваемых в метод, вы возвращаете объект, который потом можно развернуть не только в success/failure замыкания, но еще и некий always, вызывающийся всегда, независимо от успеха/провала метода.
Их также можно чередовать, объединять и делать множество приятных вещей.

Как по мне, так ключевое преимущество — это именно последовательное применение. Можно разделить flow бизнес логики на маленькие операции, вызываемые друг за другом. В итоге, например, метод получения детализации для некого товара, будет выглядеть так:

func details(id: Int) -> Promise<Void> {
    return getDetails(id)
                .then(execute: parse)
                .then(execute: save)
}

А так внутренний метод getDetails, просто делающий запрос на конкретный адрес:

func getDetails(id: Int) -> Promise<DataResponse<Any>> {
    return Promise { fulfill, reject in
        Alamofire.request(NetworkRouter.drugDetails(id: id)).responseJSON { fulfill($0) }
    }
}

Парсинг и сохранение

Не самый чистый код, но для копипасты подойдет. Прячу под кат, потому что код реально трэш. Постараюсь обновить, когда руки дойдут. Уже даже стыдно за легаси, кочующий из проекта в проект.

func parseAsDictionary(response: DataResponse<Any>) -> Promise<[String:AnyObject]> {
    return Promise {fulfill, reject in
        switch response.result {
        case .success(let value):
            let json = value as! [String : AnyObject]
            guard response.response!.statusCode < 400 else {
                let error = Error(dictionary: json)
                reject(error)
                return
            }
            fulfill(json)
            break
         case .failure(let nserror):
            let error = Error(error: nserror as NSError)
            reject(error)
            break
       }        
    }
}

// Выше этот метод уже был, но продублриюу
func save(items: [[String : AnyObject]]) -> Promise<Int> {
    return Promise {fulfill, reject in
        let realm = Realm.instance
        try! realm.write {
           items.forEach { item in
               // Замените на свой класс или сделайте generic
               realm.create(Item.self, value: item, update: true)
           }
       }
       fulfill(items.count)
    }
}

А в самом контроллере, если вы используете MVC, все максимально просто:

_ = service.details().then {[weak self] array -> Void in 
    // Success. Do w/e you like.
}

База данных
Вопрос про хранение данных описывал выше, когда говорил про работу с ORM на Go-side, поэтому повторяться не буду, только добавлю ссылку на то как получать уведомления об обновлениях базы в том же контроллере. По сути если в БД что-то добавилось, то контроллер асинхронно об этом узнает. Это гораздо удобнее, чем каждый раз мучиться с подсчетом datasource при каждом малейшем движении. А если еще и эти изменения могут произойти не только из одного места, то вообще швах.

Сюда перетаскивать кусок кода из гайда по fine-grained notifications не буду, дабы не плодить копипасту.

Для extra-ленивых

class ViewController: UITableViewController {
  var notificationToken: NotificationToken? = nil

  override func viewDidLoad() {
    super.viewDidLoad()
    let realm = try! Realm()
    let results = realm.objects(Person.self).filter("age > 5")

    // Observe Results Notifications
    notificationToken = results.addNotificationBlock { [weak self] (changes: RealmCollectionChange) in
      guard let tableView = self?.tableView else { return }
      switch changes {
      case .initial:
        // Results are now populated and can be accessed without blocking the UI
        tableView.reloadData()
        break
      case .update(_, let deletions, let insertions, let modifications):
        // Query results have changed, so apply them to the UITableView
        tableView.beginUpdates()
        tableView.insertRows(at: insertions.map({ IndexPath(row: $0, section: 0) }),
                           with: .automatic)
        tableView.deleteRows(at: deletions.map({ IndexPath(row: $0, section: 0)}),
                           with: .automatic)
        tableView.reloadRows(at: modifications.map({ IndexPath(row: $0, section: 0) }),
                           with: .automatic)
        tableView.endUpdates()
        break
      case .error(let error):
        // An error occurred while opening the Realm file on the background worker thread
        fatalError("(error)")
        break
      }
    }
  }

  deinit {
    notificationToken?.stop()
  }
}

Взаимодействие внутри проекта

Многие разработчики болеют гигантоманией, которая вызывает у них желание запихнуть всю бизнес логику в один файл с названием ApiManager.swift. Или есть более латентные формы, когда этот файл делят на много других, где каждый — это extension от ApiManager, что на самом деле совсем не лучше.
Получается перегруженный божественный класс и к тому же singleton, отвечающий просто за все. Я сам раньше так работал, когда занимался мелкими приложениями, но на крупном проекте это здорово аукнулось.

Лечится это с помощью SOA (service oriented architecture). Есть отличное видео от Rambler, где подробнейшим образом разбирается, что это такое и с чем его едят, но я постараюсь на пальцах дать инструкцию по внедрению в проект.

Вместо одного менеджера делаем несколько сервисов. Каждый сервис — это отдельный класс без состояния с набором методов по вышеописанному принципу. Сервисы также могут вызывать друг друга, это нормально. А чтобы использовать сервис в контроллере, то просто передаете ему нужный объект в конструкторе или создаете во viewDidLoad. Конечно, второй вариант хуже, но для начала сойдет. Иногда бывает, что прямо здесь и сейчас надо срочно подключить еще один сервис в контроллер, а разгребать всю эту цепочку зависимостей, проверять каждое место, где контроллер используется, нет никакого желания.

Пример таких сервисов в одном из проектов:
Как мы суслика яблоками кормили или эффективный backend на Go для iOS - 7

Где в каждом сервисе находится ограниченный набор методов, за которые он отвечает и с которыми работает. Желательно, чтобы сервис не превышал 200-300 строк. Если он перевалил за этот объем, то значит бедняга выполняет совсем не одну задачу, которая ему предназначена.

В итоге, вся ваша логика на клиенте сводится к следующему: запрос на сервер, парсинг в контейнер и сохранение в базу. Все, никаких промежуточных действий.

Заключение

Подытожу. Хорошо подготовленные данные на бэкенде в связке с Realm-ом на mobile-side дают возможность практически целиком отказаться от дополнительной бизнес логики на клиенте, сводя все к работе с интерфейсом. Можно не согласиться, но по-моему, так и должно быть. Ведь любой клиент, пусть даже и такой классный как iOS или Android, — это в первую очередь вью вашего продукта!

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

Но вот что я хочу сказать. Своей работой я стараюсь подтянуть общий уровень разработчиков в сообществе.
Вы можете сказать, что это чересчур смело. Но знаете, как становится грустно, когда на собеседование приходит человек, считающий себя классным специалистом или, по крайней мере, сильным мидлом, но при этом даже не знающий простейших вещей? Который путается между MVP и MVVM и похоже, что вообще разрабатывает интуитивно.

Из серии, когда заглядываешь человеку в код, видишь там несуразную лютую жесть и спрашиваешь его: “Вася, почему ты так сделал?” А он отвечает: “Хз, иначе не работало.”
Про архитектуру даже не заикаюсь.

Что ж, надеюсь, было полезно. И как всегда, буду рад услышать любые вопросы, критику и предложения.

P.S. Прикладываю опрос. Стоит ли делать в будущих статьях титульную информацию с кратким сюжетом и целевой аудиторией? Нигде такого не видел, но мне кажется, могло бы быть полезно. Да, есть тэги, но они, на мой взгляд, недостаточно заметны и не раскрывают сути.

Автор: Mehdzor

Источник

Поделиться