Пользователь
0,0
рейтинг
26 ноября 2013 в 20:43

Разработка → Социальная сеть без сервера. История разработки 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
Данил Никифоров @danilNik
карма
13,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (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 понятно как создаются. Но есть такой кейс: если ты в одном месте(по ошибке) присваиваешь полю не тот тип, а поля еще нет, то тогда оно создастся с этим типом и потом, когда вызовется код, который проставляет в это поле значение с правильным типом, появится ошибка.

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