Editor note: This post was originally published on the Parse blog. For more Parse news, visit the Parse site.
The Parse SDK has been and continues to be an important part of mobile development on Parse. As Parse developers, you’ve already gotten to know the Parse SDK from its public API, but today we open-sourced our SDKs so you’ll finally be able to take a peek at its inner workings.
In this post, we’ll unpack a few of the most challenging aspects of building the Parse SDKs — structuring an asynchronous API, decoupling architecture, and achieving API consistency. Over the next few weeks, we’ll publish a series of blog posts diving into even more under-the-hood features of our SDKs.
Asynchronous API
Some of the important functionalities of the Parse SDK include communicating over the network, persisting data to disk, and returning data to developers so that they can update their UI. All of this needs to happen asynchronously, in parallel, and off the main thread. With this in mind, it should be no surprise to you that the most important part of our SDK is how we do asynchronous programming. Last year, we released Tasks
as a part of Bolts, a composable promise-based library that simplifies parallelism and concurrency. We mentioned that we used it internally to solve some of our concurrency issues, but now you can finally see the extent of it.
Almost all of our internal APIs are Task
-based. We utilize them to simplify serial execution of asynchronous operations, such as persisting a dependency chain of ParseObject
s to the server, as well as parallel asynchronous operations, such as persisting batches of unrelated ParseObject
s. It’s so powerful that we’re even able to manage splicing the two together into a single asynchronous operation.
/**
* Saves a collection of objects in serial batches of leaf nodes.
*/
public Task<Void> deepSaveAsync(List<ParseObject> objects) {
if (hasCycle(objects)) {
return Task.forError(new RuntimeException("Unable to save a ParseObject with a relation to a cycle"));
}
Task<Void> task = Task.forResult(null);
List<ParseObject> remaining = new ArrayList<>(objects);
while (remaining.size() > 0) {
List<ParseObject> batch = collectLeafNodes(objects);
remaining.removeAll(batch);
// Execute each batch operation serially, awaiting until
// the previous has completed.
task = task.onSuccessTask((t) -> {
return saveAllAsync(batch);
});
}
return task;
}
/**
* Saves batches of objects in parallel.
*/
public Task<Void> saveAllAsync(List<ParseObject> objects) {
if (objects.size() > MAX_BATCH_SIZE) {
// The collection of objects is too big for a single batch,
// so partition the collection and execute each batch
// in parallel.
List<List<ParseObject>> partitioned = Lists.partition(objects, MAX_BATCH_SIZE);
List<Task<Void>> tasks = new ArrayList<>();
for (List<ParseObject> partition : partitioned) {
tasks.add(saveAllAsync(partition);
}
return Task.whenAll(tasks);
}
return executeBatchCommand(objects);
}
// * Abridged, for clarity
Writing asynchronous APIs is a breeze with Tasks
, and we highly recommend taking a deeper look both at Bolts Framework: Android, iOS/OS X and our SDKs: Android, iOS/OS X.
Decoupled architecture and API consistency
Keeping the Parse SDK experience as easy and delightful as possible is one of our highest priorities, but it’s hard to add new features and make our SDK more robust without making breaking changes. Additionally, as our codebase grows, we need to ensure that our code stays testable so that we can deliver the most stable experience to our developers.
To solve all this, we’ve adopted a decoupled architecture model that consists of our public API object instances, object states, controllers, and REST protocol. Each piece is encapsulated to ensure separation of concerns, and different implementations allow us to add new features without modifying too much code. Here’s a diagram of how this all comes together:
Object instance
The object instance is the piece that allows us to maintain an easy-to-use and unchanging API. For ParseObject
, this is the API surface layer that contains getting and setting ParseObject
properties, as well as saving, fetching, and deleting. As long as we keep this intact, we can refactor and add new features on the underlying levels without any breaking changes.
State
The state refers to the combination of the internal state of the object. For ParseObject
, it’s the current representation of itself on the server, the collection of mutations that have performed locally, and a cache of its current representation locally.
These state instances also define the interface in which the object instance and controller interact. The object instance passes its current state to the controller, the controller sends a new state back, and the object instance then updates itself with the new state.
Controller
The controller defines all the actions that can be performed on each Parse type: A ParseObject
can be saved, fetched, and deleted; a ParseQuery
can be found and counted; and a ParseFile
can be saved and fetched.
Our basic controllers serialize and deserialize our object states into our public REST format and pass along all requests to our internal networking logic. This prevents us from needing to complicate our instance and state implementations with unnecessary serialization and deserialization logic, and allows us to instrument our code for better testability.
We’ve also designed our controllers to be extendable to offer additional functionality other than communicating with Parse. One instance of this is Local Datastore. For this new feature, we were able to create another implementation of our ParseQueryController
, but instead of communicating over the network with Parse, it queried for objects locally on the device.
Stay tuned for more
In this post, we’ve covered a few major aspects of the Parse SDK architecture and how they were built. Stay tuned for future blog posts on topics like how we approach thread safety, our test philosophy, and more.
Ready to dig into the code? You can find it here for Android and iOS/OS X.