...

вторник, 26 ноября 2013 г.

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

Интро




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

Изначально клиент думал о сервере на RoR, но, видимо, они не рискнули вкладывать сразу много денег. Мы подписали строгое NDA, поэтому ссылку на Appstore я дать не могу.



Архитектура




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

Скриншот структуры 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/


This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at fivefilters.org/content-only/faq.php#publishers.


Комментариев нет:

Отправить комментарий