This is a continuation of the blog post 12 Principles of Good Software Design.
2. An idiom should make your code concise
Suppose you have the task of creating an API for your application which can communicate with a back end server. For each API call you need to signal a wait spinner, assemble a request to the back end server, send the request, wait for the response, and parse the response in JSON, signaling a library if there is a server error so it can present the user an error message.
Suppose that our server takes its arguments as JSON as part of a POST payload. Thus, our requests look like this:
POST http://api.server.co/api/1/endpointname { "arg1": value_1, "arg2": value_2 ... }
Our endpoint “endpointname” requires two arguments, “arg1” and “arg2”, which pass in integer values.
The result, for our example, looks like this:
{ "result": result }
As a first pass, we could build a class Server which creates the NSURLSession object on iOS which allows us to make a network request and get a response. The declaration looks like this:
@interface Server : NSObject + (Server *)shared; - (NSURLSession *)session; @end
The class itself simply creates a session and returns it:
#import "Server.h" @interface Server () @property (strong, nonatomic) NSURLSession *session; @end @implementation Server - (id)init { if (nil != (self = [super init])) { NSURLSessionConfiguration *config = [NSURLSessionConfiguration ephemeralSessionConfiguration]; self.session = [NSURLSession sessionWithConfiguration:config]; } return self; } + (Server *)shared { static Server *singleton; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ singleton = [[Server alloc] init]; }); return singleton; } @end
In order to make a request, we would then need to handle each of the steps above: start the wait spinner, assemble the request, send the request, and parse the response. For the example below we’ll ignore error codes and simply concentrate on the code which handles parsing the response.
Notice all the work we have to do in order to make our request:
- (void)doOperationWithCallback:(void (^)(NSInteger res))callback; { void (^copyCallback)(NSInteger val) = [callback copy]; int value1 = (do something here); int value2 = (do something here); // Start the wait spinner [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:YES]; // Assemble the request NSString *endpoint = @"endpointname"; NSString *reqString = [NSString stringWithFormat:@"%@%@",SERVERPREFIX,endpoint]; NSURL *url = [NSURL URLWithString:reqString]; NSMutableURLRequest *req; req = [[NSMutableURLRequest alloc] initWithURL:url]; [req setHTTPMethod:@"POST"]; NSDictionary *d = @{ @"arg1": @( value1 ), @"arg2": @( value2 ) }; NSData *jsonReq = [NSJSONSerialization dataWithJSONObject:d options:NSJSONWritingPrettyPrinted error:nil]; [req setHTTPBody:jsonReq]; [req setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; // Now send the request and wait for the response [[Server shared].session dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { // Process the response. Note not handling errors for now. NSDictionary *respData = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; NSInteger retVal = [respData[@"result"] integerValue]; // Dispatch back in main thread. (Our callback was in a separate // thread.) dispatch_async(dispatch_get_main_queue(), ^{ [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:NO]; copyCallback(retVal); }); }]; }
At every point in our code where we call the back end we have to write the same 38 lines of code. We have to keep track of the callback block. We have to start and stop the spinner. We have to create the request. We have to parse the response from JSON to something we can use. We have to put the response back on the main UI thread.
And notice the bugs: if we have two network requests at the same time we may inadvertently turn off the network activity spinner before network activity is complete. We aren’t handling errors.
This is most certainly not concise. There are things we can do to help reduce the line count.
The simplest is to observe that we are starting and stopping the network activity spinner every time we make a network request. So we can extend our Server class to incorporate managing the network activity spinner.
So we change our server class declaration by hiding the session object and exposing a method call instead:
@interface Server : NSObject + (Server *)shared; - (void)request:(NSURLRequest *)req completionHandler:(void (^)(NSData *data, NSURLResponse *response, NSError *error))callback; @end
Next, we create our request wrapper:
- (void)request:(NSURLRequest *)req completionHandler:(void (^)(NSData *data, NSURLResponse *response, NSError *error))callback { void (^copyCallback)(NSData *data, NSURLResponse *response, NSError *error) = [callback copy]; // Start the wait spinner [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:YES]; // Run the request [self.session dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { // Stop the wait spinner. Do this on the main thread dispatch_async(dispatch_get_main_queue(), ^{ [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:NO]; }); // Call our callback copyCallback(data,response,error); }]; }
And now we can rewrite our operation call by eliminating the wait spinner.
- (void)doOperationWithCallback:(void (^)(NSInteger res))callback; { void (^copyCallback)(NSInteger val) = [callback copy]; int value1 = (do something here); int value2 = (do something here); // Assemble the request NSString *endpoint = @"endpointname"; NSString *reqString = [NSString stringWithFormat:@"%@%@",SERVERPREFIX,endpoint]; NSURL *url = [NSURL URLWithString:reqString]; NSMutableURLRequest *req; req = [[NSMutableURLRequest alloc] initWithURL:url]; [req setHTTPMethod:@"POST"]; NSDictionary *d = @{ @"arg1": @( value1 ), @"arg2": @( value2 ) }; NSData *jsonReq = [NSJSONSerialization dataWithJSONObject:d options:NSJSONWritingPrettyPrinted error:nil]; [req setHTTPBody:jsonReq]; [req setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; // Now send the request and wait for the response [[Server shared].session dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { // Process the response. Note not handling errors for now. NSDictionary *respData = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; NSInteger retVal = [respData[@"result"] integerValue]; // Dispatch back in main thread. (Our callback was in a separate // thread.) dispatch_async(dispatch_get_main_queue(), ^{ copyCallback(retVal); }); }]; }
The code is shorter, but concise? Hardly.
There is an advantage to this code, by the way. Because our wait spinner code is now in one location rather than scattered everywhere where we make a request, we can easily update our wait spinner code to fix the bug of hiding the wait spinner prematurely when two network requests are made at the same time, by wrapping our code in a counter and hiding the spinner only when the count reaches zero.
Notice a couple of things about our server requests, however. First, our server example above always takes POST requests. Second, it always takes and returns JSON requests. Additionally we assume only the endpointname changes; the rest of the URL remains the same.
So why are we doing all this duplicate work?
Let’s move the functionality into our Server class.
First, we know we always take an endpoint name and an (optional) dictionary of parameters. And we know the return result will always be a JSON response, so instead of returning raw data we return our parsed data. So our server class looks like this:
@interface Server : NSObject + (Server *)shared; - (void)requestWithEndpoint:(NSString *)endpoint parameters:(NSDictionary *)params completionHandler:(void (^)(NSDictionary *data, NSURLResponse *response, NSError *error))callback; @end
Our server should now handle all the common work we scattered throughout our code:
#define SERVERPREFIX @"http://api.server.co/api/1/" - (void)requestWithEndpoint:(NSString *)endpoint parameters:(NSDictionary *)params completionHandler:(void (^)(NSDictionary *data, NSURLResponse *response, NSError *error))callback { void (^copyCallback)(NSDictionary *data, NSURLResponse *response, NSError *error) = [callback copy]; // Start the wait spinner [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:YES]; // Assemble the request NSString *reqString = [NSString stringWithFormat:@"%@%@",SERVERPREFIX,endpoint]; NSURL *url = [NSURL URLWithString:reqString]; NSMutableURLRequest *req; req = [[NSMutableURLRequest alloc] initWithURL:url]; [req setHTTPMethod:@"POST"]; if (params) { NSData *jsonReq = [NSJSONSerialization dataWithJSONObject:params options:NSJSONWritingPrettyPrinted error:nil]; [req setHTTPBody:jsonReq]; [req setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; } // Now send the request and wait for the response [[Server shared].session dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { // Process the response. Note not handling errors for now. NSDictionary *respData = nil; if (data) { respData = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; } // Dispatch back in main thread. (Our callback was in a separate // thread.) dispatch_async(dispatch_get_main_queue(), ^{ [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:YES]; copyCallback(respData,response,error); }); }]; }
Note that our single entrypoint is handling most of the common work that would otherwise be scattered throughout our code.
And our doOperationWithCallback method?
- (void)doOperationWithCallback:(void (^)(NSInteger res))callback; { void (^copyCallback)(NSInteger val) = [callback copy]; int value1 = (do something here); int value2 = (do something here); NSDictionary *d = @{ @"arg1": @( value1 ), @"arg2": @( value2 ) }; [[Server shared] requestWithEndpoint:@"endpointname" parameters:d completionHandler:^(NSDictionary *data, NSURLResponse *response, NSError *error) { NSInteger retVal = [data[@"result"] integerValue]; copyCallback(retVal); }]; }
We’ve made our code concise.
What we’re doing has become extremely apparent, because all the duplicate bookkeeping work is now handled elsewhere in a common location.
We don’t have to worry about wait spinners or about constructing the request object–all we have to worry about is which endpoint are we calling, and what parameters are we passing, and what results we’re expecting.
An idiom should make your code concise.
Because by making your code concise you implicitly do a few things. First, you move repetitive code to another common location, where bugs (such as our wait spinner bug) can be easily fixed in one place. Second, you reveal the purpose of your function call by eliminating all the repetitive bookkeeping that distracts from the purpose of the code. And third, you reduce the probability of error.
For example, can you spot what is wrong with the following code?
- (void)doOperationWithCallback:(void (^)(NSInteger res))callback; { void (^copyCallback)(NSInteger val) = [callback copy]; int value1 = (do something here); int value2 = (do something here); // Start the wait spinner [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:YES]; // Assemble the request NSString *endpoint = @"endpointname"; NSString *reqString = [NSString stringWithFormat:@"%@%@",SERVERPREFIX,endpoint]; NSURL *url = [NSURL URLWithString:reqString]; NSMutableURLRequest *req; req = [[NSMutableURLRequest alloc] initWithURL:url]; [req setHTTPMethod:@"POST"]; NSDictionary *d = @{ @"arg1": @( value1 ) }; NSData *jsonReq = [NSJSONSerialization dataWithJSONObject:d options:NSJSONWritingPrettyPrinted error:nil]; [req setHTTPBody:jsonReq]; [req setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; // Now send the request and wait for the response [[Server shared].session dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { // Process the response. Note not handling errors for now. NSDictionary *respData = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; NSInteger retVal = [respData[@"result"] integerValue]; // Dispatch back in main thread. (Our callback was in a separate // thread.) dispatch_async(dispatch_get_main_queue(), ^{ [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:NO]; copyCallback(retVal); }); }]; }
Did you spot the error?
Think about the requirements we stated above, where our endpoint requires two arguments.
How about now? Can you spot the error now?
- (void)doOperationWithCallback:(void (^)(NSInteger res))callback; { void (^copyCallback)(NSInteger val) = [callback copy]; int value1 = (do something here); int value2 = (do something here); NSDictionary *d = @{ @"arg1": @( value1 ) }; [[Server shared] requestWithEndpoint:@"endpointname" parameters:d completionHandler:^(NSDictionary *data, NSURLResponse *response, NSError *error) { NSInteger retVal = [data[@"result"] integerValue]; copyCallback(retVal); }]; }
Yep, we forgot a parameter to our endpoint.
And it is easier to spot the error in 15 lines of code which reduce the process to its essentials, than in 38 lines of code full of extraneous bookkeeping code.