Intro
I want to talk about the experience of developing an iOS client for a social network and a backend implemented using BaaS
Parse.com. The following is the architecture that we got, some tips & tricks and thoughts about working with parse.com.
Initially, the client thought about the server on RoR, but apparently they did not dare to invest a lot of money at once. We signed a strict NDA, so I can’t give a link to the Appstore. By the good tradition of all IT books, I want to thank customer X and company Y for having had the opportunity to work on this project and learn from all this experience. Also, thanks to A. for writing the part about the module for embedded purchases.
Architecture
I have seen and heard many times about projects that exchanged between different developers and at the same time lost the integrity of the idea, so I decided to write a small document that sets out the basic principles on which I built the architecture of the application. I believe that for the most part, all the experience I gained on this project was concentrated in it.
Screenshot of the Xcode project structure Logical layers
Work with network
In the application we work with two services:
PubNub and
Parse . All interaction with the SDK of these services occurs on this layer.
- Parse services
Parse has its own iOS SDK, but we didn’t want to become strongly attached to it, because the client said that they plan to launch their server later. Therefore, we used the Parse RESTfull API, which we interacted with through AFNetworking. We transferred all the business logic that could be transferred to the server to the cloud code - it turned out that each request caused the server code. In principle, it was possible to create complex queries by stuffing parameters into the NSDictionary, but after I figured out Backbone.js, on which the cloud code is written, I began to do everything there - this is much more readable and better amenable to change. As a result, the application sent only one request to the server for each user action. Only on login and update screens with different information sent more requests.
Ui
This layer is the easiest - there is only ViewControllers, Views, Cells and an additional controller that helps in navigation and implements various tricks with showing screens in unexpected places. For example, if a user logs in via facebook, he shows a screen with the fields that the user must fill out if they are not in his facebook account. Also located here are controllers that respond to push notification. We needed to do drag-n-drop for the UICollectionView — as a result, we used a ready-made implementation:
github.com/lxcid/LXReorderableCollectionViewFlowLayout . I had to podshamanit a little, but, in general, you can use the code.
I can also recommend
MNMPullToRefresh if you need pull to refresh control for UITableView.
Data
We use Magic Recording to work with CoreData: there are no other additional classes, except for the class that connects to the database of a specific user.
- Core data
- Magic Recording
- DataBaseManager class
- Datasources
Separate classes for UIViewController, which contains the logic of interaction with the database and server. - Models
We have two main types of models - local database models and intermediate information models that come to us from the server.
Synchronization Layer
The logical layer on which we translate objects received from the server into database objects and vice versa (if necessary). If there is no object with the current id, we add it to the database. If there is, then just update the information from the server.
For models with a large number of fields, a generalized method of filling them with values ​​is implemented using runtime functions:
watch code-(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
Layer for synchronization of arrays of objects - calls the appropriate class from the ParseObjectSync layer for the corresponding model - ParseObjectSync
The layer on which the logic is described, where we assign the Core Date model to each Parse model. Also convert fields if necessary. - Chat engine
The UI works with the network and the database only through a layer of synchronization classes. The Chat Engine class also lies between UI and Pubnub with CoreDate. It turned out to be large enough, but not so much that it was possible to distinguish from it a separate class, which would be called according to the occupied layer. Although, most likely, I just did not have the desire to do it.
In app purchase
In the application, the domestic currency, Coins (Coins), was conceived from the very beginning, which the user can easily buy using the In-App Purchase mechanism, but there is always something to spend on that :). From Apple's in-app purchase, they are a Consumable Product, i.e. you need to be very careful about recording and accounting for the receipt / expenditure of coins, otherwise the user will lose money and be upset.
')
It was decided to make this thin layer without using third-party libraries. We decided to keep the Coins ourselves in the User model on parse.com, and not locally. This affected the way the completion code worked. After all, we must wait for the moment when the Coins changes are written to parse.com and only after that do finishTransaction. Here is a great place to use Block-a, which stores the context for completing a transaction while we make a request to the server. This approach gave us the opportunity to log in from different devices and always have up-to-date information about the current user's Coins.
Another thing that is not usually done: SKPaymentTransactionObserver (the class that implements this protocol) must be created when the application starts and live its whole life, as there may come an incomplete transaction that was not completed during the last application launch. We did not create our Singleton here.
Work Circle Controller
In the course of development, more and more actions appeared that had to be carried out in a specific period of time and between such and such a request. For example, to maintain the consistency of the local database, it was necessary to first load the user's pictures, and only then the chats that refer to these pictures. There were also a lot of nuances in business logic: show a screen with required fields to fill in if the user registered via facebook and subscribe to push notifications after login, because it’s not a fact that the login will happen simultaneously with the receipt of the token. You also need to unsubscribe from push notifications after logout, initialize some services immediately after launch, and others only after login. After all the logic of the sequence of launching the services and the life of the application was concentrated in a separate class, it became much easier to live. The class, by the way, is not a singleton - it lives in AppDelegate. As a result, only 146 lines of code remained in AppDelegate.
Work with Parse.com
In general, I liked working with Parse, but it is still not clear how applications with which traffic volume can work stably. At the moment, the service gives the following limits for one account (Pro plan):
Burst limit : 40 requests per second
API requests limit : 160 - you will not find this in the documentation (at the time of writing this is not)
Restriction on the execution of the cloud code function: 15 seconds
Background job limit: 15 minutes
Our application has not yet gained a large number of users, and it is not entirely clear how it will behave in production, but there are doubts about this. The application is already reaching a limit on the number of API requests. I contacted the Parse team about the transition to the Enterprise plan with the following characteristics:
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
They replied that 1000 requests per second would cost $ 14,000 per month. After that, I asked them how to reduce the number of requests, and described the operation of our application. They replied that 1000 requests per second for our application is fully justified, and it is unlikely to be able to do less.
At Parse, for the time being, one cannot simply raise another environment for testing and development on the same database model. We have to create a new application and almost manually create the same data model.
In terms of restrictions on the number of requests Parse loses
Kinvey . I specifically learned about this restriction from Kinvey, and this is what they answered: "You can get for $ 1,400 per month you can get BaaS, on which there can be 50,000 active users per month, 3 environments, and business logic is limited to 50 scripts. At the same time, one support script was defined as follows: S lot S S S S S S S S S S S S BL BL so desires. ”How everything works in practice, I do not know, but it looks attractive.
Cloud code on Backbone.js
As a person who wrote only in languages ​​with strict typing and never touched the backend, it was very interesting for me to learn backbone.js. The main difficulties encountered:
- Callback hell.
decided using the async.js library - Debugging
For writing code I used Sublime. Then I came across a post about how to set up the environment in Cloud9, and on the same day I found a message from the author of this instruction, in which he shared a problem: after updating the service to deploy the Parse code to the Parse server, everything stopped working because the python version on Cloud9 does not support some features. As a result, everything remained on Sublime, and debugging occurred only after deploy and running the code on the server - Testing.
See below. It deserves a separate chapter.
Integration testing
Preparing the environment for testing
As one smart person said, the most difficult thing in tests is setting up an environment for testing.
In the course of development, more and more logic accumulated on the server code and more and more scripts appeared for testing. It became clear that without automatic tests, the writing of the cloud code would take an enormous amount of time. As I wrote above, debugging was possible only after deploying to the server, until the code was run it was impossible to even know about the presence of syntax errors, only if they are not directly related to deployment.
As a result, we set up an environment for testing. In tests, we run all necessary services using Work Circle Controller. Using the flags for the preprocessor, we install the code that we need to run for the test environment:
watch code #import //UI #if !TEST #import #endif //Net #import <Parse/Parse.h> #import #import #import #if !TEST #import //Data #import #import #import #import //Sync #import #import #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 }
Next, we created separate classes in which we put the implementation of various scripts that the user can execute.
In the base class, a single method is implemented:
- (void) setUpWithBlock:(void (^) (NSError* error))block{ [NSURLRequest setAllowsAnyHTTPSCertificate:YES forHost:@"api.parse.com"];
Next, we created an environment and logic for two users and for individual scenarios. Since the interaction with the server is asynchronous, we used
SRTAdditions.h to get the kalbek and perform the tests correctly.
Idea development
The main goal of this approach was to reduce the time spent on testing different scenarios. I think it’s realistic to set up Jasmine or Mocha on Parse, so some cases would be easier to solve through unit tests, but overall the integration tests were fully justified: the builds became more stable, the time to develop new features for the cloud code decreased, and It was playing tennis while all the tests were being done.

After I started playing too much time in tennis, the authorities
got worried and decided to pick up the server at
hudson-ci.org , which takes the latest code from git, and runs sh scripts that run the tests. To run tests and beautifully display logs,
xtool was used. By the way, when running through the console, the tests do not start the simulator, and you can continue to work on the code while the tests are performed.
Localization
For localization, I finally began to use a very simple
tool :
agi18nTips & Tricks