Социальная сеть без сервера. История разработки iOS-клиента и backend

    Интро


    Я хочу рассказать об опыте разработки iOS-клиента для социальной сети и бэкенда реализованного с помощью BaaS Parse.com Нижe приведена архитектура, которая у нас получилась, некоторые tips&tricks и размышления по поводу работы с parse.com.
    Изначально клиент думал о сервере на RoR, но, видимо, они не рискнули вкладывать сразу много денег. Мы подписали строгое NDA, поэтому ссылку на Appstore я дать не могу. По доброй традиции всех IT книг, хочу выразить благодарность заказчику Х и компании Y за то что мне довелось поработать над этим проектом и подчерпнуть весь этот опыт. Также спасибо А. за то, что написал часть про модуль для встроеных покупок.

    Архитектура


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


    Логические слои

    Работа с сетью

    В приложении мы работаем с двумя сервисами: PubNub и Parse. Все взаимодействие с SDK этих сервисов происходит на этом слое.
    • Message Center
      • PubNub SDK

    • Parse Services
      У Parse есть свое iOS SDK, но мы не хотели сильно привязываться к нему, так как клиент говорил, что они планируют запустить свой сервер позднее. Поэтому мы использовали Parse RESTfull API, с которым взаимодействовали через AFNetworking. Всю бизнес-логику, которую можно было перенести на сервер, мы перенесли на cloud code — получилось, что каждый запрос вызывал серверный код. В принципе, можно было составлять сложные запросы, запихивая параметры в NSDictionary, но после того, как я разобрался с Backbone.js, на которой пишется cloud code, я стал все делать там — это гораздо читабельнее и лучше поддается изменениям. В итоге получилось, что на каждое действие пользователя приложение посылало только один запрос к серверу. Только на login и обновление экранов с разной информацией посылалось большее количество запросов.

    UI

    Этот слой самый простой — тут только ViewControllers, Views, Cells и дополнительный контроллер, который помогает в навигации и реализует разные хитрости с показыванием экранов в самых неожиданных местах. Например, если пользователь производит регистрацию через facebook, то он показывает экран с теми полями, которые должен заполнить пользователь, если их нет в его facebook аккаунте. Также здесь располагаются контроллеры, реагирующие на push notification. Нам необходимо было сделать drag-n-drop для UICollectionView — в результате использовали готовую реализацию: github.com/lxcid/LXReorderableCollectionViewFlowLayout. Пришлось немного подшаманить, но, в целом, пользоваться кодом можно.
    Также могу порекомендовать MNMPullToRefresh, если вам нужен pull to refresh контрол для UITableView.

    Data

    Для работы с CoreData используем Magic Recording: никаких других дополнительных классов нету, кроме класса, который подключается к базе данных конкретного пользователя.
    • Core Data
      • Magic Recording
      • DataBaseManager class
    • DataSources
      Отдельные классы для UIViewController, где содержится логика взаимодействия с БД и сервером.
    • Models
      Мы имеем два основных типа моделей — модели локальной базы данных и промежуточные модели информации, которая приходит к нам с сервера.

    Synchronisation Layer

    Логический слой, на котором мы переводим объекты, полученные от сервера, в объекты БД и обратно(если есть необходимость). Если объекта с текущим id нету — добавляем его в БД. Если есть, то просто обновляем информацию с сервера.
    Для моделей с большим количеством полей реализован обобщенный метод заполнения их значениями с использованием runtime функций:
    смотреть код
    -(id)syncLocal:(id)local withClass:(Class)localClass fromParse:(id)parse{
        NSAssert([parse isKindOfClass:[UserStatistic class]], @"wrong class");
        UserStatistic*stat =(UserStatistic*)parse;
        LocalUserStatistics* newLocalStat = (LocalUserStatistics*)local;
        if(!newLocalStat)
            newLocalStat=[super syncLocal:local withClass:localClass fromParse:parse];
        
        unsigned int outCount;
        Protocol* protocol = objc_getProtocol("StatisticsProtocol");
        objc_property_t *propList = protocol_copyPropertyList(protocol, &outCount);
    
        NSArray* noSetProps = [self propertiesDontNeedToSet];
        for (int i = 0; i < (int)outCount; i++) {
            objc_property_t * oneProp = propList + i;
        	NSString *propName = [NSString stringWithUTF8String:property_getName(*oneProp)];
            if([noSetProps indexOfObject:propName]==NSNotFound){
                id newValue =[stat valueForKey:propName];
                if(newValue && newValue!=[NSNull null]){
                    [newLocalStat setValue:newValue forKey:propName];
                }
            }
        }
        free(propList);
        
        return  newLocalStat;
    }

    • ParseObjectsSync
      Слой для синхронизации массивов объектов — вызывает соответствующий класс из слоя ParseObjectSync для соответствующей модели
    • ParseObjectSync
      Слой, на котором описана логика, где мы каждой модели Parse ставим в соответствие модель Core Date. Также преобразуем поля, если это необходимо.
    • Chat Engine
      UI работает с сетью и БД только через прослойку классов синхронизации. Класс Chat Engine тоже лежит между UI и Pubnub c CoreDate. Он получился достаточно большим, но не настолько, чтобы можно было выделить из него отдельный класс, который бы назывался в соответствии с занимаемым слоем. Хотя, скорее всего, у меня просто не хватило желания это сделать.

    In App Purchase

    В приложении с самого начала задумывалась внутренняя валюта — Coins (Монетки), которые пользователь может легко купить, используя механизм In-App Purchase, ну а потратить всегда есть на что :). С точки зрения Apple's In-App Purchase, они являются Consumable Product, т.е. нужно очень осторожно подходить к записи и учету прихода/расхода монеток, иначе пользователь потеряет деньги и расстроится.

    Было решено сделать этот тонкий слой без использования сторонних библиотек. Сами Coins мы решили хранить в модели User на parse.com, а не локально. Это повлияло на то, как работает код завершения тразакции. Ведь мы должны дождаться момента, когда изменения Coins запишутся на parse.com и только после этого делать finishTransaction. Здесь отличное место для использования Block-a, который хранит контекст для завершения транзакции, пока мы делаем запрос на сервер. Такой подход дал нам возможность заходить в систему с разных устройств и всегда иметь актуальную информацию о Coins текущего пользователя.

    Еще одна вещь, которую обычно не делают: SKPaymentTransactionObserver (класс, который реализует этот протокол) должен создаваться при старте приложения и жить всю его жизнь, так как туда может прийти незавершенная транзакция, которая не была завершена в прошлый запуск приложения. Cвоих Singleton-ов мы здесь не создавали.

    Work Circle Controller

    В ходе разработки появлялось все больше и больше действий, которые нужно было произвести в конкретный период времени и между таким-то и таким-то запросом. Например, для поддержания консистентности локальной БД нужно было загрузить сначала картинки пользователя, и только потом чаты, которые ссылаются на эти картинки. Также было много нюансов в бизнес-логике: показать экран с обязательными полями для заполнения, если пользователь зарегистрировался через facebook и подписаться на push notifications после логина, ведь не факт, что логин произойдет одновременно с получением токена. Также необходимо отписаться от push notifications после logout, инициализировать одни сервисы сразу после запуска, а другие только после логина. После того как вся логика последовательности запуска сервисов и жизни приложения была сосредоточена в отдельном классе, жить стало гораздо легче. Класс, кстати, не синглтон — он живет в AppDelegate. В итоге в AppDelegate осталось всего лишь 146 строчек кода.

    Работа с Parse.com


    В целом, работать с Parse понравилось, но до сих пор не понятно, приложения с каким объемом трафика на нем могут работать стабильно. На данный момент сервис дает следующие лимиты на один аккаунт (Pro plan):
    Burst limit: 40 запросов в секунду
    API requests limit: 160 — этого вы не найдете в документации (на момент написания статьи этого нет)
    Ограничение на выполнение cloud code функции: 15 секунд
    Ограничение на выполнение background job: 15 минут

    Наше приложение еще не набрало большого количества пользователей, и не совсем понятно, как оно поведет себя в продакшене, но есть сомнения на этот счет. Приложение уже достигает лимита на количество API запросов. Я контактировал с командой Parse по поводу перехода на Enterprise plan со следующими характеристиками:

    Total users: 1000
    Users performing request in the same time: 500
    API requests performing in the same time: 1000
    API calls burst limit: 1000
    Cloud code burst limit: 2000

    Они ответили, что 1000 запросов в секунду будут стоить $14 000 в месяц. После чего я спросил у них, как можно уменьшить количество запросов, и описал работу нашего приложения. Они ответили, что 1000 запросов в секунду для нашего приложения — вполне оправдано, и меньше сделать вряд ли получится.

    На Parse пока что нельзя просто поднять еще одну среду для тестирования и разработки на той же модели БД. Приходится создавать новое приложение и практически вручную создавать такую же модель данных.

    В плане ограничения на количество запросов Parse проигрывает Kinvey. Я специально узнал об этом ограничении у Kinvey, и вот что они ответили: «You are correct — we do not limit number of requests per second (or on total requests or API calls in any way).» За $1 400 в месяц можно получить BaaS, на котором могут быть 50 000 активных пользователей в месяц, 3 среды, а бизнес-логика ограничивается 50 скриптами. При этом один скрипт саппорт определил так: «BL scripts are written in their own containers within the Kinvey web console, so a BL script is defined as each chunk of JS code — certainly quite a lot can be fit into a single BL script if one so desires.» Как все работает на практике, я не знаю, но выглядит привлекательно.

    Cloud code на Backbone.js


    Мне, как человеку, писавшему только на языках со строгой типизацией и никогда не прикасавшемуся к backend, было очень интересно изучать backbone.js. Основные сложности с которыми столкнулся:
    • Callback hell.
      решил с помощью использования библиотеки async.js
    • Debugging.
      для написания кода использовал Sublime. Потом наткнулся на пост о том, как настроить среду в Cloud9, и в тот же день нашел сообщение автора этой инструкции, в котором он поделился проблемой: после обновления сервиса для деплоя нового кода на сервер Parse у него все перестало работать, потому что версия python на Cloud9 не поддерживает некоторые функции. В итоге все так и осталось на Sublime, и дебаггинг происходил только после deploy и запуска кода на сервере
    • Тестирование.
      Смотри ниже. Это заслуживает отдельной главы


    Интеграционное тестирование


    Подготовка среды для тестирования
    Как сказал один умный человек, самое сложное в тестах — это настройка среды для тестирования.
    В ходе разработки на серверном коде скапливалось все больше логики и появлялось все больше сценариев для тестирования. Становилось ясно, что без автоматических тестов написание cloud code будет занимать колосальное количество времени. Как я уже писал выше, дебажить можно было только после деплоя на сервер, до момента запуска кода нельзя было узнать даже о наличии синтаксических ошибок, только если они не связаны непосредственно с деплоем.
    В итоге мы настроили среду для тестирования. В тестах мы с помощью Work Circle Controller запускаем все необходимые сервисы. С помощью флагов для препроцессора устанавливаем тот код, который нам нужно запустить для тестовой среды:
    смотреть код
    
    #import "WorkCircleController.h"
    
    //UI
    #if !TEST
    #import "LoginViewsManager.h"
    #endif
    
    //Net
    #import <Parse/Parse.h>
    #import "ParseRESTClient.h"
    #import "StatisticService.h"
    #import "ProfilePicturesSync.h"
    
    #if !TEST
    #import "ChatsEngine.h"
    
    //Data
    #import "DataBaseManager.h"
    #import "LocalUser.h"
    #import "LocalLockSlot.h"
    #import "LocalPicture.h"
    
    //Sync
    #import "SyncManager.h"
    #import "UserSync.h"
    #else
    
    #endif
    
    #if !TEST
    @interface WorkCircleController(){
        ProfilePicturesSync* profilePicturesSync;
    }
    
    @property(nonatomic, weak) AppDelegate* appDelegate;
    @property(nonatomic, strong) LoginViewsManager* loginManager;
    @property(nonatomic, strong) NSData* deviceToken;
    @end
    
    #endif
    
    
    @implementation WorkCircleController
    
    #if !TEST
    - (id)initWithDelegate:(AppDelegate*)appDelegate
    {
        self = [self init];
        if (self) {
            self.appDelegate = appDelegate;
            profilePicturesSync = [ProfilePicturesSync new];
        }
        return self;
    }
    
    
    
    #pragma mark - Push notification
    
    - (void)app:(UIApplication*)application didRegisterForRemoteNotificationsWithToken:(NSData*)deviceToken{
        self.deviceToken = deviceToken;
        if(self.state == LifeStateLoginDataAndNetLayersReady || self.state == LifeStateLoggedIn)
            [self subscribeToPushes:deviceToken];
    }
    
    - (void)subscribeToPushes:(NSData *)deviceToken {
        [self subscribeToParsePushes:deviceToken];
        [self subscribeToChatPushes:deviceToken];
    }
    
    - (void)subscribeToParsePushes:(NSData *)deviceToken {
    ...
    }
    
    - (void)subscribeToChatPushes:(NSData *)deviceToken {
    ...
    }
    
    
    - (void)unsubscribeFromPushesInParseWithBlock:(void (^)(NSError *error))block {
    ...
    }
    
    #endif
    
    
    #pragma mark - Pre Login Logic
    
    -(void)setupPreLoginStateWithBlock:(void (^) (NSError* error))block{
        [self prepareNetManagersForPreLoggin];
    #if !TEST
        [self performUIUpdatesForPreLogginWithBlock:^(NSError *error) {
            if(block)
                block(error);
        }];
    #else
        finishWithErrorBlock(nil);
    #endif
    }
    
    -(void)prepareNetManagersForPreLoggin{
        [[ParseRESTClient sharedClient] startParseService];
    }
    
    #if !TEST
    -(void)performUIUpdatesForPreLogginWithBlock:(void (^) (NSError* error))block
    {
        ...
    }
    #endif
    
    #pragma mark after register Login Logic
    - (void)afterRegistrationWithBlock:(void (^) (NSError* error))block{
    ...
    }
    
    #pragma mark - Login Logic
    -(void)loginWithBlock:(void (^) (NSError* error))block{    
        [self prepareDataManagersForLogginWithBlock:^(NSError *error) {
            [self prepareNetManagersForLogginWithBlock:^(NSError *_error) {
    #if !TEST
                [self performUIUpdatesForLogginWithBlock:^(NSError *uIError) {
                    if(block)
                        block(uIError);
                    [self performAfterLoginUpdatesWithBlock:^(NSError *afterLoginError) {
                        
                    }];
                }];
    #else
                if(finishWithErrorBlock)
                    finishWithErrorBlock(error);
    #endif
            }];
        }];
    }
    
    -(void)prepareDataManagersForLogginWithBlock:(void (^) (NSError* error))block{
    #if !TEST
        [DataBaseManager setupDataBaseWithUserId:[PFUser currentUser].email];
        [[Settings defaultSettings] setUsername:[PFUser currentUser].email];
    #endif
        block(nil);
    }
    
    -(void)prepareNetManagersForLogginWithBlock:(void (^) (NSError* error))block{
        [[ParseRESTClient sharedClient] updateToken];
    #if !TEST
        ...
    #endif
        block(nil);
    }
    
    #if !TEST
    - (void)performUIUpdatesForLogginWithBlock:(void (^) (NSError* error))block{
        ...
    }
    
    - (void)performAfterLoginUpdatesWithBlock:(void (^) (NSError* error))block {
       ...
    }
    #endif
    
    #pragma mark - Logout Logic
    
    
    -(void)logoutWithBlock:(void (^)(NSError *error))block{
    #if !TEST
    ...
    #else
        [PFUser logOut];
        [[ParseRESTClient sharedClient] closeClient];
        block(nil);
    #endif
    }
    
    - (void)deleteAccountWithBlock:(void (^)(NSError *error))block{
        [[ParseRESTClient sharedClient] closeClient];
    #if !TEST
        ...
    #else
        [PFUser logOut];
        block(nil);
    #endif
        
    }


    Далее мы создали отдельные классы, в которых поместили реализацию разных сценариев, которые может выполнить пользователь.
    В базовом классе реализован единственный метод:

     - (void) setUpWithBlock:(void (^) (NSError* error))block{
        [NSURLRequest setAllowsAnyHTTPSCertificate:YES forHost:@"api.parse.com"]; //отключаем проверку сертификатов, иначе iOS не дает нам общаться через https в тестовой среде. Это приватное АПИ.
        self.workCircleController = [WorkCircleController new];
        [self.workCircleController setupPreLoginStateWithBlock:^(NSError *error) {
            block(error);
        }];
    }


    Далее мы создали среду и логику для двух пользователей и для отдельных сценариев. Поскольку взаимодействие с сервером реализовано асинхронное, то мы использовали SRTAdditions.h для получения калбеков и правильного исполнения тестов.
    Развитие идеи
    Основной целью этого подхода было уменьшение времени на тестирование разных сценариев. Я думаю, что на Parse реально настроить Jasmine или Mocha, поэтому некоторые кейсы было бы проще решать через юнит-тесты, но в целом интеграционные тесты вполне оправдали себя: билды становились все стабильнее, время на разработку новых фич на cloud code уменьшилось, и можно было поиграть в теннис, пока выполняются все тесты.
    image
    После того, как в теннис я стал играть слишком много времени, начальство забеспокоилось и решило поднять сервер hudson-ci.org, который берет последний код с git, и запускает sh скрипты, которые запускают тесты. Для запуска тестов и красивого отображения логов использовали xtool. Кстати, при запуске через консоль тесты не запускают симулятор, и можно дальше работать над кодом, пока выполняются тесты.

    Локализация

    Для локализации в итоге стал использовать очень простой тул: agi18n

    Tips & Tricks

    • Почему надо включать warnings в проекте статья 1, статья 2
    • Снипеты (шаринг через dropbox):
      • __weak typeof(self) weakSelf = self;
      • (void (^)(NSError *error))<#block#>
    • Code style от New York Times
    • Reveal. Очень любопытный инструмент для отладки UI. Очень удобно, когда неясно, куда уехала вью или когда дают на поддержку новый большой проект и мало времени, чтобы разобраться в том, как устроена архитектура вью-контроллеров и где какие вью.
    • удобный тул для генерирования бесчисленного количества иконок всевозможных размеров
      www.gieson.com/Library/projects/utilities/icon_slayer
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 27
    • +1
      Спасибо за обзор!
      Тоже сделали соцсеть на базе Parse а-ля Instagram, но пока до лимитов на запросы она не добралась. Надеюсь, Parse работает над этой проблемой, иначе дорого.
      • 0
        А как вы делаете UI? Используете ли сториборды, ксибы?
        • 0
          В этом проекте все было реализовано на ксибах. А вообще я не против использования сторибордов.
          • 0
            чем плохи сториборды? (спрашиваю как совсем новичек в этом деле, желаюсь поучиться) у меня вот в гриде три вида кастомных ячеек и я все сделал через сториборд, а не отдельными ксибами (я начинающий и приложение маленькое), догадываюсь что подход не правильный, но выглядит все не так уж плохо. Где правда? )
            • 0
              Я не хочу устраивать очередной холивар на эту тему, потому что ее уже разобрали по косточкам. Например тут: www.raywenderlich.com/51992/storyboards-vs-nibs-vs-code-the-great-debate
              (см. комментарии)
              • 0
                Если кратко — сториборд
                1) тормозит. При достаточном колиестве контроллеров, айпадовский тормозит на топовых аймаках этого года
                2) если верстать юай прямо в нем, то нужно будет все то же самое повторять для айпад-версии
                НО — его можно использовать как карту контроллеров, а вьюхи контроллеров держать в ксибах — ммы например так и делаем.
                • 0
                  Спасибо за ответ.
                  Вот именно как «карта контроллеров» меня и привлек сториборд, да и в учебниках «для начинающих» не упоминается о подходе с ксибами, хотя с подходом «чисто код» много примеров, а вот с ксибами нет (может проглядел, но не попадалось). А вот как начал копать глубже то уже и пошло кодом и ксибами. Попробую ваш подход в следущем приложении, если оно конечно будет :))
                  Сейчас меня просто вогнал в ступор подход к событям. После c# это просто какой-то ад) Еще и такие противоречивые мнение про эти штуки типа KVO и т.д. Думаю что бы такого нагородить, наверно в итоге будет какойто мрак типа делегатов с методами, которые будут менять состояние UI элементов.
                  • 0
                    Не так все и сложно. А KVO используется не очень часто, в нашем довольно большом проекте — около 2х раз.
                    • 0
                      Код с KVO можно упростить с ReactiveCocoa. Применял его в нескольких поектах, хотя и не в полный рост.
                      • 0
                        Ой, не надо всех этих надстроек. Obj-c и Cocoa сами по себе достаточно выразительны и лаконичны, а там где нет — легко написать свой макрос.
          • +1
            А расскажите пожалуйста попобробнее про «Callback hell» и чем помог async.js? Сам веду проект использующий Parse в качестве бэкенда. Очень интересен Ваш опыт в этом плане :)
            • 0
              На cloud code основная сложность в том, чтобы вызвать финальный callback только после выполнения всех необходимых операций. Все операции идут асинхронно. Самый простое решение, расположить вызовы функций таким образом:
              image
              И в конечном callback, самой последней функции, мы просто вызываем наш финальный callback, который возвращает ответ клиенту.
              Поначалу я забивал на этот ужас, а потом заинтересовался как же все-таки можно хотя бы(!) уменьшить ширину кода, есть ли какой-нибудь syntactic sugar. Сначала, я начал везде пользоваться promises — они делают код чуть чуть читабельнее, все-таки это не было панацеей и тогда я нашел async.js, которая позволяет получать финальный callback, после завершения сколь угодно большого количества функций выполняющихся параллельно, последовательно, в цикле с разными параметрами и т.д.

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

              функция очень большая, и читать не удобно.
              exports.deletePictureFromUserProfile = function(deletingPictureId, customCallBack) {
              	var arrayForDelete = [];
              	picturesQuery = new Parse.Query("Picture");
              	picturesQuery.equalTo("objectId", deletingPictureId);
              	var query = new Parse.Query("LockSlot");
              	query.matchesQuery("picture", picturesQuery);	
              	query.find().then(function(results){
              		if(results){
               		   for(var i=0,l=results.length; i < l ; i++){
              			   	results[i].unset("picture");
               				results[i].save(null,{
               					success: function(myObject) {
               						return [];
               					},
               					error: function(myObject, error) {
               						console.error(error);
               						return [];
               					}
               				});
               			}
              		}else{
              			return [];
              		}
              	}).then(function(results){
              		var query = new Parse.Query("xxxxx");
              		query.matchesQuery("onePicture", picturesQuery);
              		query.find().then(function(results){
              			arrayForDelete.push.apply(arrayForDelete,results);
              			return arrayForDelete;
              		}).then(function(results){
              			var query = new Parse.Query("xxxx");
              			query.matchesQuery("onePicture", picturesQuery);
              			query.find().then(function(results){
              				arrayForDelete.push.apply(arrayForDelete,results);
              				return arrayForDelete;
              			}).then(function(results){
              				var query = new Parse.Query("xxxx");
              				query.matchesQuery("pictureFrom", picturesQuery);
              				query.find().then(function(results){
              					arrayForDelete.push.apply(arrayForDelete,results);
              					return arrayForDelete;
              				}).then(function(results){
              					var query = new Parse.Query("xxxxx");
              					query.matchesQuery("pictureTo", picturesQuery);	
              					query.find().then(function(results){
              						arrayForDelete.push.apply(arrayForDelete,results);
              						return arrayForDelete;					
              					}).then(function(results){
              
              						var query = new Parse.Query("xxxxx");
              						query.matchesQuery("picture", picturesQuery);
              						query.find().then(function(results){
              							arrayForDelete.push.apply(arrayForDelete,results);
              							return arrayForDelete;												
              						}).then(function(results){
              							var query = new Parse.Query("xxxxx");
              							query.matchesQuery("picture", picturesQuery);
              							query.find().then(function(results){
              								customCallBack();
              							}).then(function(results){
              								var query = new Parse.Query("Picture");
              								query.equalTo("objectId", deletingPictureId);
              								query.first().then(function(image){
              									arrayForDelete.push(image);
              									if(arrayForDelete){
              										console.log("we got arrray for delete");
              										var num_of_deleted_objects = 0;
              										for(var i=0,l=arrayForDelete.length; i < l ; i++){
              											var object = arrayForDelete[i];
              											if(object){
              												object.destroy().then(function(results){
              													num_of_deleted_objects++;
              													if(num_of_deleted_objects==l){
              														customCallBack();
              													}
              												});
              											}
              										}
              									}
              								}, function(error) {
              									console.error(error);
              									customCallBack();
              								});
              							}, function(error) {
              								console.error(error);
              								customCallBack();
              							});
              						}, function(error) {
              							console.error(error);
              							customCallBack();
              						});
              					}, function(error) {
              						console.error(error);
              						customCallBack();
              					});
              				}, function(error) {
              					console.error(error);
              					customCallBack();
              				});
              			}, function(error) {
              				console.error(error);
              				customCallBack();
              			});	
              		}, function(error) {
              			console.error(error);
              			customCallBack();
              		});
              	}, function(error) {
              		console.error(error);
              		customCallBack();
              	});
              }


              А с async.parallel так:
              функция очень большая, но читать удобнее
              function deletePictureObjFromUserProfile(picture, customCallBack){
              	var resultArrayForDelete = [];
              	resultArrayForDelete.push(picture);
              	async.parallel([
              		function(callback){
              			var query = new Parse.Query("xxxx");
              			query.equalTo("pict", picture);	
              			query.find().then(function(results){
              				
              				if(results.length>0){
              					var currentOperation =0;
              					var numOfOperation = results.length;
              					function enumCallBack(){
              						currentOperation++;
              						if(currentOperation==numOfOperation)
              							callback();
              					};
              		 		   for(var i=0,l=results.length; i < l ; i++){
              					   	results[i].unset("pict");
              		 				results[i].save(null,{
              		 					success: function(myObject) {
              		 						enumCallBack();
              		 					},
              		 					error: function(myObject, error) {
              		 						console.error(error);
              		 						enumCallBack();
              		 					}
              		 				});
              		 			}
              				}else{
              					callback();
              				}
              			},
              			function(error){
              				console.error(error);
              				callback();
              			});
              		},
              		function(callback){
              			var query = new Parse.Query("xxxxx");
              			query.equalTo("onePict", picture);	
              			query.find().then(function(results){
              				resultArrayForDelete.push.apply(resultArrayForDelete, results);
              				callback();
              			},
              			function(error){
              				console.error(error);
              				callback();
              			});
              		},function(callback){
              			var query = new Parse.Query("xxxxx");
              			query.equalTo("anotherPict", picture);	
              			query.find().then(function(results){
              				resultArrayForDelete.push.apply(resultArrayForDelete, results);
              				callback();
              			},
              			function(error){
              				console.error(error);
              				callback();
              			});
              		},function(callback){
              			var query = new Parse.Query("xxxxxx");
              			query.equalTo("pictFrom", picture);	
              			query.find().then(function(results){
              				resultArrayForDelete.push.apply(resultArrayForDelete, results);
              				callback();
              			},
              			function(error){
              				console.error(error);
              				callback();
              			});
              		},function(callback){
              			var query = new Parse.Query("xxxxxxx");
              			query.equalTo("pictTo", picture);	
              			query.find().then(function(results){
              				resultArrayForDelete.push.apply(resultArrayForDelete,results);
              				callback();
              			},
              			function(error){
              				console.error(error);
              				callback();
              			});
              		},function(callback){
              			var query = new Parse.Query("xxxxxxxxxxx");
              			query.equalTo("pict", picture);	
              			query.find().then(function(results){
              				resultArrayForDelete.push.apply(resultArrayForDelete, results);
              				callback();
              			},
              			function(error){
              				console.error(error);
              				callback();
              			});
              		},function(callback){
              			var query = new Parse.Query("xxxxxxx");
              			query.equalTo("pict", picture);
              			query.find().then(function(results){
              				resultArrayForDelete.push.apply(resultArrayForDelete,results);
              			},
              			function(error){
              				console.error(error);
              				callback();
              			});
              		}
              	],
              	function(error, results){
              		if(error)
              			console.error(error);
              		if(resultArrayForDelete.length>0){
              			Parse.Object.destroyAll(resultArrayForDelete, function(success, error) {
              				if(error)
              					console.error(error);
              				customCallBack();
              			},
              			function(error){
              				customCallBack();
              			});
              		}else{
              			customCallBack();
              		}
              		
              	});
              }
              • +1
                У стандартных Parse.Promises есть замечательная особенность: их можно выстраивать в цепочки. Т.е. если один из коллбеков then (success или error) вернет Promise, то Promise возвращенный сам then не будет завершен пока не завершится Promise из коллбека. В коде выглядит немного симпатичнее чем на словах :)

                Чище всего выглядят тесты, поэтому приведу кусок оттуда. Я использую для тестирования JQUnit. ok() и equal() — это функции проверок этого фреймворка, а start(); — функция завершающая асинхронный тест.

                       asyncTest("Register and activate account", function() {
                            var password = "SECRET",
                                user, newUser,
                                deed, registrationId;
                
                            Parse.Cloud.run("register", {
                                params: {
                                    firstName: "REMOVEME",
                                    lastName: "REMOVEME"
                                },
                                password: password
                            }).then(function(id) {
                                registrationId = id;
                                return Parse.User.signUp("testUser", "testPassword").then(function() {
                                    return Parse.Promise.as();
                                }, function(error) {
                                    return Parse.User.logIn("testUser", "testPassword");
                                });
                            }).then(function() {
                                return Parse.Cloud.run("activate", {
                                    id: registrationId,
                                    password: password
                                }).then(function() {
                                    ok(false, "User can activate if Deed is not created!");
                                    return Parse.Promise.error();
                                }, function(error) {
                                    // Если вернуть удачно завершенный Promise даже из error callback, дальше 
                                    // по цепочке будет вызван success callback
                                    // Тот же фокус наоборот сработает для success callback выше
                                    ok(true, "Can't activate if deed is not created yet");
                                    return Parse.Promise.as();
                                });
                            }).then(function() {
                                // Тут интересный момент: и success и error callback возвращают пустой успешно
                                // завершенный Promise. А это значит мы всегда попадем в Teardown
                                ok(true, "All ok!");
                                return Parse.Promise.as();
                            }, function(error) {
                                ok(false, error && error.message ? error.message : JSON.stringify(error));
                                return Parse.Promise.as();
                            }).then(function() {
                                // Teardown
                                return Parse.Object.destroyAll(_.compact([user, newUser, deed]));
                            }).then(function() {
                                start();
                            }, function() {
                                start();
                            });
                        });
                


                Прокомментировал в коде пару трюков.

                Я так понимаю что для любой CommonJS совместимой реализации Promises этот код будет выглядеть примерно так же. После того как я освоил эти трюки мне сильно захотелось что-то похожее получить на стороне клиента (IOS). Смотрел в сторону ReactiveCocoa. Но почему-то там все так красиво не выходит.

                А еще мне сильно упростило код использование расширения классов. Выглядит у меня это примерно так:

                 beens.Points = Parse.Object.extend("Points", {}, {
                        get: function (user, date) {
                            var query = new Parse.Query("Points");
                            query.equalTo("user", user);
                            query.equalTo("date", date);
                            return query.first().then(function (record) {
                                if (typeof record === "undefined") {
                                    record = new beens.Points();
                                    record.set("user", user);
                                    record.set("userId", user.id);
                                    record.set("date", date);
                                }
                                return Parse.Promise.as(record);
                            });
                        }
                    });
                


                С этой библиотечкой все становится еще красивее: github.com/icangowithout/parse-ph

                Для декларации Cloud функций я сделал свою обертку, которая ждет из функции реализующей логику Promise и уже его результат интерпретирует как:

                .then(function (result) {
                    response.success(result);
                }, function (error) {
                    console.log("ERROR: " + JSON.stringify(error));
                    response.error(error);
                });
                


                Прошу прощения за огрызок. Код слегка не универсален и ждет совего рефакторинга.

                Как я написал выше, для тестов мне приглянулся JQUnit. Я просто размещаю тесты в каталоге public и открываю как обычные веб страницы. Причем я использую отдельное приложение для тестирования, без или с минимумом ограничений прав (по тесту это видно :)). И планирую использовать еще одно для тестирования с установленными ограничениями. И одно для публикации рабочей версии приложения. Пока даже не могу до конца оценить насколько эта мысль была удачной. Явных проблем еще не выявил.

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

                Есть еще маленькое ноухау по поводу тестирования. Но пока все на уровне экспериментов. Возможно когда-нибудь напишу статью :)))

                PS: Спасибо за статью! Было очень приятно краем глаза взглянуть на устройство чужих проектов использующих этот backend. В сети пока не так уж много интересных решений на этот счет. Слохно найти хороший пример.
                • +1
                  Еще один момент, возможно кому-то будет полезен:
                  В моем случае IDE Brackets + модуль JSHint вычищают значительную часть самых глупых ошибок еще до убликации кода на Parse. А команда:

                  parse develop <имя приложения>
                  


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

                  Кроме того, для Brackets уже достаточно много интересных модулей, делающих разработку приятнее.
                  • 0
                    А async.js удобно использовать, когда тебе надо параллельно несколько функций запустить, например, когда у тебя запуск cloud code не укладывается в timeout.

                    Да, тут надо всю информацию по разработке на Parse в отдельную статью оформлять. Я еще могу поделиться тем, как писал код для подсчета статистики — там я столкнулся и c timeout и с burst limit. А еще с тем, что на parse не работают setTimeout() и sortBy()
                    • +1
                      Для параллельных процессов в Parse.Promise есть метод when(). Выглядит примерно так:

                              return Parse.Promise.when([userQuery.find(), 
                                                         commentQuery.find(), 
                                                         Parse.Cloud.run("someFunction")])
                              .then(function(users, comments, someResult) {
                                  return Parse.Promise.as("Bingo!");
                              }, function(userError, commentsError, callError){
                                  return Parse.Promise.error("Error!");
                              });
                      


                      А по поводу статистики будет очень интересно :) Я строил рейтинги пользователей. И для меня, как человека выросшего на SQL, построить эту часть системы было особенно сложно.
                  • 0
                    Ну я предполагал, что можно красивое решение найти, жаль что мы тут поспешили. Спасибо за очень полезную информацию!

                    А можешь поподробнее описать как ты environment для тестов поднял?
                    «Я просто размещаю тесты в каталоге public и открываю как обычные веб страницы» то есть папка public лежит в cloud на parse? А как там код можно запускать не через API, а в браузере?
                    • +1
                      Да все верно тесты лежат в виде обычных html-страниц в cloud на Parse. Сейчас они запускаются только вручную.
                      Тесты разбиваются на несколько отдельных страниц, для удобства тестирования отдельных подсистем.

                      В Parse сейчас заведено 3 приложения. 1-е для публикации, 2-е для разработчиков клиентской части и тестирования со всеми ограничениями, 3-е для backend разработки и тестирования. На текущем этапе эту схему полноценно пока реализовать не удалось. По сути большая часть работ происходит в 3-м приложении в т.ч. разработка клиентской части.

                      Тестируемые компоненты делятся на 2-х типа:

                      1. Cloud Code
                      Для них через JavaScript API создаются тестовые данные и проверяется работоспособность функций и триггеров. В тесте жестко прописаны ключи приложения которое тестируется. Для этих целей у нас заведено отдельное тестовое приложение с ослабленными ограничениями прав доступа.

                      2. Модули не зависимые от Parse API
                      Такой код выносится в отдельные Javascript модули и тестируется, как обычная JavaScript библиотека независимо. Модуль дублируется в public для теста и cloud для Cloud кода. В браузере такие модули я подключаю через require.js. В Cloud Code через его родной require().

                      С такими тестами очень удобно отслеживать результаты запросов. Уточнить какие-то вещи отладной кода теста. Можно выполнять запросы вручную.

                      CI в проекте я пока не внедрял. JQUnit умеет генерировать отчеты в XML, а сервер непрерывной интеграции может запускать их через безголовый браузер вроде phantome.js. Планирую поднять его позже.
              • 0
                Не могли бы вы объяснить, почему для чата использовался платный сервис PubNub, а не XMPP?
                • 0
                  Для использования XMPP нужен сервер. Клиент не хотел сервера ни в каком виде. Даже для шедулинга и запуска некоторых скриптов на Parse мы использовали беслатный сервис iron.io, только потому что клиент настаивал на server less решении.
                  • +1
                    В следующий раз приглашаю опробовать наш QuickBlox — вот готовый пример iOS кода чата на XMPP, оптимизированный нами и заскейленный амазоном. XMPP сервер автоматически предоставляется с админкой и прочим фаршем. По цене на платных/enterprise пакетах дешевле, чем Parse и Kinvey + есть уникальные фичи, такие как видеозвонки, которых у прочих BaaS просто нет.

                    Если интересно, напишу статью здесь о том, как легко вставить текстовый чат и видеочат в своё iOS или Android приложение.
                    • 0
                      Мы рассматривали Ваш сервис в качестве backend для нашего проекта на начальных этапах. И честно говоря выбор был сложный :)

                      С радостью почитал бы про его преимущества в сравнении с Parse. Интереснее всего узнать про Ваш ответ на Cloud Code, типовое устройство системы безопасности, тестирование и подключение сторонних компонент :)

                      Хотя и использование XMPP тема тоже весьма актуальная.
                      • 0
                        было бы интересно почитать статью про чат, и думаю не только мне!
                  • +1
                    По поводу сниппета __weak typeof(self) weakSelf = self; рекомендую libextobjc с его @weakify/@strongify
                    • 0
                      кстати — маленький лайфхак с базой для тестирования в Parse. У нас необходимость в тестовой базе появилась только после выхода в продакшн, так что само приложение под iOS было готово. Я завел новое приложение в parse.com, подставил ключики в iOS приложение, разрешил в настройках парса создание классов на стороне пользователя, потыкался по всему функционалу приложения и вуаля — на парсе пустая база со всеми нужными классами и полями. По крайней мере справедливо для parseSDK.
                      • 0
                        Да, вроде это тоже работает, но полной уверенности в том что вся модель простроиться нету. Например не совсем понятно как будут создаваться Pointer на объекты. Пару раз мы давали тестерам протестировать работу приложения с не обновленной моделью базы для, специально чтобы проверить как работает автосоздание полей, вроде все ок, но при обновлении продакшен БД на такие эксперементы я бы не пошел.
                        • 0
                          Вернее Pointer понятно как создаются. Но есть такой кейс: если ты в одном месте(по ошибке) присваиваешь полю не тот тип, а поля еще нет, то тогда оно создастся с этим типом и потом, когда вызовется код, который проставляет в это поле значение с правильным типом, появится ошибка.

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