Swift, like JavaScript asynchronous calls are based on callbacks. This means that when calling an asynchronous function, one of the arguments is another function (or code block) to be called when the asynchronous work is finished. Although the action is different from a synchronous call, the code is nevertheless similar. For example:
func syncFunction() -> String {
//some work here
return "result"
}
//call sync
let x = syncFunction()
print(x)
func asyncFunction(callback: @escaping (String) -> Void) {
DispatchQueue.global().async {
//some work
callback("result")
}
}
//async call
asyncFunction() {
res in
print (res)
}
While the structure of the two functions is different, their use is not so much different. The result of a synchronous function is taken by assigning its call to a variable, and the result of an asynchronous function is given in the block after calling the function itself. The record still looks quite similar. Of course, the essence of asynchronicity is that we get the result after some time and the thread calling the function will not wait for its execution, but will take care of the next instructions.
The situation starts to get complicated with the increase of our function calls. Let's assume that we need to download the user's data from the server, which includes his ID, which we can use to download his projects and messages waiting for him.
func sUser(userName: String) throws -> CustomUserData {
/// fetch data
return UserData(userId: 5)
}
func sGetProjects(userId: Int) throws -> [String] {
///fetch data
return ["p1", "p2", "p3"]
}
func sGetMessages(userId: Int) throws -> [String] {
return ["m1", "m2", "m3"]
}
do {
let u = try sUser("jaro")
let p = try? sGetProjects(u.userId)
let m = try? sGetMessages(u.userId)
} catch {
/// handle me!
}
Downloading all data requires three independent calls. They cannot be made simultaneously because the second and third require data returned by the first. Asynchronously it would look like this:
func aUser(_ userName: String, callback: @escaping ( (UserData, Error?)) -> Void) {
callback( (UserData(userId: 5),nil))
}
func aGetProjects(_ userId: Int, callback: @escaping ( ([String],Error?)) -> Void) {
callback( (["p1", "p2", "p3"],nil))
}
func aGetMessages(_ userId: Int, callback: @escaping ( ([String],Error?)) -> Void) {
callback( (["m1", "m2", "m3"],nil))
}
var userData: UserData?
var projects: [String]?
var messages: [String]?
aUser("jaro") {
tuple in
let (user,error) = tuple
if error != nil {
//handle me
return
}
userData = user
aGetProjects(user.userId) { tuple in
let (p,e) = tuple
projects = p
//handle e
}
aGetMessages(user.userId) { tuple in
let (m,e) = tuple
messages = m
//handle e
}
}
The second and third call should not start if the first one returned error. After all, we don't have the data to make the requests.
Correct execution of all calls will give us several sets of data. Related but independent objects. Let's consider how to return such data. Let's assume that downloading a person's data together with his or her projects is a whole, which suggests that we shouldn't force the client of our functions to call each of them on their own. Let's try to construct a function that gives the full result.
We can pack the complete set of data into a structure or a tuple. For simplicity, I'll use a tuple in the example.
func allUserData(name: String, callback: @escaping ( (UserData?, [String]?, [String)?, Error? ) -> Void) {
var userData: UserData?
var projects: [String]?
var messages: [String]?
aUser(name) {
tuple in
let (user,error) = tuple
if error != nil {
//handle me
callback( (nil,nil,nil, error ) )
return
}
userData = user
let d = DispatchGroup() //1
d.enter() //2
aGetProjects(user.userId) { tuple in
defer { d.leave() } //3
let (p,e) = tuple
projects = p
//handle e
}
d.enter() //2 znowu
aGetMessages(user.userId) { tuple in
defer { d.leave() } //3 znowu
let (m,e) = tuple
messages = m
//handle e
}
d.notify(queue: .global()) { //4
callback( (userData, projects, messages, nil) )
}
}
}
The function does not handle errors correctly, but this is not the main concern now. We have a situation where we call two functions in parallel and have to wait for both to finish before returning the complete result. We can use DispatchGroup (//1) here. Each enter the group (//2) we balance with leave (//3), and when all the tasks are finished we will get a notification (//4), in which we can put together the final result. In my opinion such solution is correct because we directly mark which calls are independent of each other, and our allUserData function returns the whole data to the caller.
What if you went another way? Let's say that aUser will download the id of the user and start downloading the rest of the data. If we feel it is not so important to download it simultaneously, we can put each subsequent call in the previous callback and avoid using DispatchGroup-style design. This may seem to be the more attractive the more calls you need to make to have a complete set of data. We can go even further and aUser may not have its callback, instead the last called function will use NotificationCenter to notify the client that the data is already there.
It could look like this:
class DataManager {
var userData: UserData?
var projects: [String]?
var messages: [String]?
func loadData(user: String) {
aUser("jaro") {
user in
let (u,e) = user
self.userData = u
aGetProjects(u.userId) { tuple in
let (p,e) = tuple
self.projects = p
//handle e
aGetMessages(u.userId) { tuple in
let (m,e) = tuple
self.messages = m
//handle e
NotificationCenter.default.post(
name: NSNotification.Name("allLoaded"), object: nil)
}
}
}
}
}
// usage:
let d = DataManager()
d.loadData(user: "jaro")
// some work
/...
@objc func allLoaded() {
print(d.userData) //...
}
And? What do you think? At first sight, this code doesn't look bad. The use is trivial, we have everything under control, the client code is free of blocks of callbacks. What's more, extending such code is easier than the previous one. If you have to download attachments to messages as well, you can rewrite the DataManager class so that there is another one in the call chain and you don't have to change the client code (unless you need attachments for something). Does this solution have any disadvantages?
It does.
In my opinion, if you put such code into your private project, which you do after work, in a very limited amount of time and you know in advance that the code will not be much bigger than what you initially planned, then you can agree to such code. But if the project is large, still under development, this approach can very easily lead to a situation where it is difficult to manage the code. Possible problems that I think may arise (in principle, not so much "may$quot;, but will arise if the project is developed long enough):
- If you sometimes need to call one of the above functions independently of the others - you may find that it pulls other calls. You may need to refactor to arrange this.
- If more than one place in the application downloads user data, the notification of data downloading will get both. It looks like it will have to be refactored :)
- If you need to call a function that turns out to be the last function in a string of calls, you may find that it will send a notification that the data loading is complete, the effects may be different. This should be kept in mind.
- Architecture around such a solution causes that in one place of the code we have "some" call, and in another we expect the data to exist in some predefined place. At this point, the very way of using applications or organizing windows becomes an element of business logic. Additionally, not described anywhere.
- For the same reason as in the previous point, testing will also be difficult.
- If one of the calls returns an error, you don't know how to handle it, can you add another error notification? You see where we're going?
- Single Responsibility Principle will be broken repeatedly. If not even now, then in the future. And it will be difficult to fix.
- And the most dangerous, in my opinion: This code makes it easy to add more things to the DataManager, and it's hard to do anything else. If the application will grow, with each subsequent modification it will be more and more difficult to make changes outside the manager (because there is everything you need in it and how to download it differently?) and at the same time, because of packing more and more things to the manager - it's also more and more difficult to make changes in it.
I would suggest introducing an additional application layer (let's call it a service layer). It can have one asynchronous method and return data in callback. And it can call individual services and put the data together. In case of an error it can ignore it or pass it in response, which will protect us from surprises.
What if another part of the application needs to download similar data?
Well, if it's the same data - it can use the same method.
If they're a little different, I think it would be a better idea to create separate method for that, even if it duplicates some of the calls to lower layers. I'm assuming here that the call itself (which I understand as a function preparing e.g. rest parameters, calling service, and then returning its results) is kept in the lower data access layer anyway, so the duplication I'm writing about concerns calling the function of the lower layer and not the whole code preparing the call.
I suggest that you at least keep these suggestions in mind when writing the code, in my opinion, they may prove to be a very successful investment in the long term.
Comments
Post a Comment