46

SwiftNIO: A simple guide to async on the server [FREE]

 5 years ago
source link: https://www.tuicool.com/articles/hit/I3a6z2z
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

An important topic in server-side Swift and SwiftNIO is asynchronous programming . Asynchronous programming is a way of programming that enables the program to work on multiple tasks in parallel by switching to a different task when waiting for an external factor. It allows large numbers of users to access your website at the same time and keeps the back end running at 88 miles per hour.

Two key aspects of asynchronous programming are futures and promises . Using a website that keeps track of quotes, this tutorial will teach you how to work with futures and promises. So let’s begin, before the event loop runs away!

For this tutorial, you will need Xcode 10 or later, and you will need a client for sending HTTP requests such as RESTed .

Before you get down to writing some code, you’ll start by getting some theoretical understanding of one of the most important concepts in iOS development, and asynchronous development in general, the Event Loop .

The Event Loop

Efficiency is an important goal when writing server-side code. The more efficient your code, the more users it can help concurrently. Where an unoptimized app can only help ten to a hundred users at a time, highly optimized apps are able to help a hundred-thousand concurrent users on the same hardware.

The main reason for this difference is architectural. A poorly optimized app will be idle most of the time, whereas a highly optimized app will try to spend its time cleverly and actively. The brains behind this architecture in server-side Swift is provided by SwiftNIO , Apple’s cross-platform event-driven networking framework, by a type called EventLoop .

EventLoop is, in its essence, a while loop that keeps looking to do work when an external factor, such as a user uploading a file, has made progress.

If one thread writes to a variable, and another thread tries to read it or write to it at the same time, a race condition occurs, which will crash your app. One method you can use to prevent race conditions from occurring is using locks for each of these variables. You’ll lock this lock before accessing a variable and unlock is after accessing it is completed, so any future accesses to the resource are prevented until you’ll unlock the lock. The main downside of this approach is its complexity and impact on performance.

Using a single thread to access this information is another way to tackle the issue of race conditions. Doing this means all read and write operations are done by this thread. One example of this is the database you’ll find in the sample project. Using this method requires you to give the read/write thread a way to return the result to the event loop that requested it.

If you respond to a request from a different EventLoop , SwiftNIO will crash the app to prevent undefined behaviour from causing trouble.

Futures and Promises

Future is a type used to describe information that does not exist yet, but will exist in the future . Writing asynchronous code with futures means directing the futures to respond to the successful results or to the failures. You can create a future with a predefined result: either success or failure. However, if the result is not known yet , you need to create a Promise .

These promises and futures need to be created from the EventLoop since the future type will always return to the event loop it originated from. A future can only hold one result at a time, at which point it is considered completed . Completion can be either successful or unsuccessful, thus failed futures are also considered completed. Finally, if a promise is creates, it must be completed.

With this short theoretical introduction, time to get your hands dirty with some code!

Getting Started

To start, click the Download Materials button at the top or bottom of this tutorial to download the project files. The starter project is set up with a web server and mocked database.

Open Terminal and navigate into the Starter project folder.

OpenFolder-1.gif

Take note of the Package.swift file in the Starter folder. This file is your app’s manifest file, describing its sources and dependencies. It is Swift Package Manager’s (SPM) equivalent of CocoaPods’ Podspec.

Run this command in Terminal:

swift package generate-xcodeproj

This downloads the dependencies of this project and generates an Xcode project file .

When SPM has finished, an Xcode project file appears in the project folder. Open it!

Select the BasicAsync target and make sure Xcode’s target device is set to My Mac .

change-target.gif

With that, you’re set up! Click the Run button in Xcode to start the web server. You should see the following output in the Xcode console:

Server started and listening

This means that you’re all set! Open your new website at http://localhost:8080 and welcome the future!

browser.png

The Quotes Repository

Now that you have a web server up and running, it is time to add some more interesting functionality that will allow you to see futures and promises in action. By the end of this tutorial, you will be able to create new quotes, list all or selected quotes and delete quotes.

The Repository Pattern is used to work with the Quote model. You will call QuoteRepository to operate on the quotes in the database. The repository needs the EventLoop so that it can create promises. The database is static so that it is shared between all repository instances.

You are going to edit two files: QuoteResponder.swift and QuoteRepository.swift .

QuoteResponder will receive the API requests from a browser, or from a different tool of your choice, such as cURL or RESTed, and work out what route is being called and whether it should create, list or delete quotes. The methods in this class will call the methods in QuoteRepository , which will send instructions to the database and then return promises to QuoteResponder . When the promises have completed asynchronously, QuoteResponder will wrap the successful or erroneous result into a HTTPResponse and send it back.

Setting up the Quotes Repository

First, in QuoteRepository.swift , add the following to the QuoteRepository class to handle the insertion of a new quote:

func insert(_ quote: Quote) -> EventLoopFuture<Void> {
  // 1
  let promise = eventLoop.newPromise(of: Void.self)
  // 2
  QuoteRepository.database.addEntity(quote, completing: promise)
  // 3
  return promise.futureResult
}

In the following code you:

  1. Create a promise with a Void return type that will indicate success or failure. The promise is created on the current EventLoop to ensure that the correct thread will receive the future’s result.
  2. Request the QuoteRepository to store the quote in the database, passing it the promise to use to indicate failure or success when finished.
  3. Finally, return the promise’s future to the route so that the route will receive the result of the database operation once it has completed asynchronously.

Add the following method to QuoteRepository as well:

func fetchAllQuotes() -> EventLoopFuture<[Quote]> {
  let promise = eventLoop.newPromise(of: [Quote].self)
  QuoteRepository.database.getAllEntities(completing: promise)
  return promise.futureResult
}

This method is very similar to the previous one, but it creates a promise wherein the return type is an array of Quotes . Then, it prompts the database to gather all the stored quotes, after which the method returns the promise’s future.

EventLoop-Specific Repositories

Before you create the routes, you need to create a factory method for the QuoteRepository because requests can run on different EventLoop s. If the EventLoop changes while responding to a request, SwiftNIO will crash to prevent undefined behaviour from breaking the app.

Add the following code to QuoteResponder to set up the repository factory method:

// 1
let quoteRepository = ThreadSpecificVariable<QuoteRepository>()

func makeQuoteRepository(for request: HTTPRequest) -> QuoteRepository {
  // 2
  if let existingQuoteRepository = quoteRepository.currentValue {
    return existingQuoteRepository
  }

  // 3
  let newQuoteRepository = QuoteRepository(for: request.eventLoop)
  quoteRepository.currentValue = newQuoteRepository
  return newQuoteRepository
}

What’s going on here?

  1. A ThreadSpecificVariable holds the repository because the value will be different for each thread. Thanks to this, you don’t need to worry about thread safety.
  2. makeQuoteRepository(for:) returns a value unique to the current thread if one is available.
  3. Otherwise, a new QuoteRepository is created. The HTTPRequest is passed along so that its EventLoop can be used.

Now, you’re ready to write the first routes. To keep the code separated, each route will get its own method.

Fetching Quotes

First, add the listQuotes method to QuoteResponder , right below the last piece of code you added:

private func listQuotes(for request: HTTPRequest) -> EventLoopFuture<HTTPResponse> {
  // 1
  let repository = makeQuoteRepository(for: request)

  // 2
  return repository.fetchAllQuotes().thenThrowing { quotes in
    // 3
    let body = try HTTPBody(json: quotes, pretty: true)
    return HTTPResponse(status: .ok, body: body)
  }
}

Going through the above code:

  1. You uses a quote repository to fetch all the quotes needed, which you get using the factory method that you just created.
  2. Next, you use the repository’s fetchAllQuotes() method you added earlier.
  3. Once the promise has completed, you encode the returned quotes into a HTTPBody object as JSON using the thenThrowing method. You wrap the body in a HTTPResponse and send it back to the client.

The map function on a Future type transforms the future to a different type. The thenThrowing function on a future does the same, except it allows the transform function to throw an error.

In this case, the array of quotes is transformed into an HTTPResponse . The thenThrowing function is used because encoding an entity as JSON can throw an error.

Note : The web server used in this example project will emit a HTTP 500 Internal Server Error if any errors are thrown.

Creating New Quotes

The next route will let the client create new quotes. Add the following method to the QuoteResponder struct:

private func createQuote(from request: HTTPRequest) -> EventLoopFuture<HTTPResponse> {
  // 1
  guard let body = request.body else {
    return request.eventLoop.newFailedFuture(error: QuoteAPIError.badRequest)
  }

  do {
    // 2
    let quoteRequest = try body.decodeJSON(as: QuoteRequest.self)
    let quote = Quote(id: UUID(), text: quoteRequest.text)

    // 3
    let repository = makeQuoteRepository(for: request)

    // 4
    return repository.insert(quote).thenThrowing {
      let body = try HTTPBody(json: quote, pretty: true)
      return HTTPResponse(status: .ok, body: body)
    }
  } catch {
    // 5
    return request.eventLoop.newFailedFuture(error: error)
  }
}

Here is what is happening in this code:

HTTPBody
Quote
HTTPResponse

Routing the Requests

To get everything up and running with more than just the static welcome message, replace the contents of the respond(to:) method in QuoteResponder with the following code:

// 1
switch request.head.method {
case .GET:
  // 2
  return listQuotes(for: request)
case .POST:
  // 3
  return createQuote(from: request)
default:
  // 4
  let notFound = HTTPResponse(status: .notFound, body: HTTPBody(text: "Not found"))
  return request.eventLoop.newSucceededFuture(result: notFound)
}

Breaking down this code:

  1. First, you switch on the method of the request.
  2. If this is a GET request, you call the listQuotes(for:) method to return all quotes as JSON.
  3. If its a POST request, you’ll instead call createQuote(from:) to insert a new quote.
  4. In all other cases, simply return a 404 Not Found HTTPResponse .

Stop the server running in Xcode and run it again to make it use your new methods.

Open RESTed and point it to http://localhost:8080/ . Change the method to POST , rather than GET . The second table contains the body of the request. Add a key-value pair with the key text . Add your quote for the value and be sure to change the type from Form-encoded to JSON-encoded , otherwise you’ll get an Internal Server Error.

Press Send Request on the bottom right and you’ll see a response body returned with the quote you just submitted and an ID.

request1.png

To check if it worked, open the website at http://localhost:8080 . A JSON array with the quote you just inserted will show up.

browser2.png

Great Scott! The futures worked, excellent work!

Take a moment to bask in the glory of success before you move on to the next section, where you’ll take the necessary steps to delete quotes.

Fetching One Quote

Before you can delete quotes, the repository needs to implement the ability to fetch a single quote since deleting a quote will find, delete and then return the deleted quote.

To do this, add the following method to QuoteRepository :

func fetchOne(by id: Quote.Identifier) -> EventLoopFuture<Quote?> {
  let promise = eventLoop.newPromise(of: Quote?.self)
  QuoteRepository.database.findOne(by: id, completing: promise)
  return promise.futureResult
}

This is similar to the fetchAllQuotes() method except for the fact you use the supplied id to call the database’s findOne(by:completing:) method. It returns a promise with a return type of an optional Quote , since the database may not contain a quote with this ID.

Next, add the following method to QuoteResponder :

private func getQuote(by id: String, for request: HTTPRequest)
  -> EventLoopFuture<HTTPResponse> {
    // 1
    guard let id = UUID(uuidString: id) else {
      return request.eventLoop.newFailedFuture(error: QuoteAPIError.invalidIdentifier)
    }

    // 2
    let repository = makeQuoteRepository(for: request)

    // 3
    return repository.fetchOne(by: id).thenThrowing { quote in
      // 4
      guard let quote = quote else {
        throw QuoteAPIError.notFound
      }

      // 5
      let body = try HTTPBody(json: quote, pretty: true)
      return HTTPResponse(status: .ok, body: body)
    }
}

This is what happens, here:

  1. You attempt to create a UUID from the received id and return an error if this is unsuccessful.
  2. Since the repository is needed to access the database, you get a QuoteRepository next.
  3. You attempt to fetch the from the repository using fetchOne(by:) .
  4. If the quote doesn’t exist, you throw an error.
  5. Finally, you encode the quote as JSON and return it.

Implementing a findOne Route

To link up a findOne request into the respond method, you’ll need to replace the code for the GET case in the respond(to:) method implementation. Add the following code:

case .GET:
  // 1
  guard request.head.uri != "/" else {
    return listQuotes(for: request)
  }
  
  // 2
  let components = request.head.uri.split(separator: "/", maxSplits: .max, omittingEmptySubsequences: true)
  
  // 3
  guard let component = components.first,
        components.count == 1 else {
    return request.eventLoop.newFailedFuture(error: QuoteAPIError.notFound)
  }
  
  // 4
  let id = String(component)
  return getQuote(by: id, for: request)

Going through this step by step:

listQuotes(for:)

Try it out! Restart the app, create a quote and send a GET request to http://localhost:8080/uuid , replacing uuid with the ID of the quote you just inserted.

Deleting Quotes

Now, for erasing history! First, add this method to QuoteRepository :

func deleteOne(by id: Quote.Identifier) -> EventLoopFuture<Void> {
  let promise = eventLoop.newPromise(of: Void.self)
  QuoteRepository.database.deleteOne(by: id, completing: promise)
  return promise.futureResult
}

Like in all the other methods in this class, you create a promise, call a database method providing the promise as its callback, and then return the promise’s future.

Next, add the following method to QuoteResponder :

private func deleteQuote(by id: String, for request: HTTPRequest)
  -> EventLoopFuture<HTTPResponse> {
    // 1
    guard let id = UUID(uuidString: id) else {
      return request.eventLoop.newFailedFuture(error: QuoteAPIError.invalidIdentifier)
    }

    let repository = makeQuoteRepository(for: request)

    return repository.fetchOne(by: id).then { quote -> EventLoopFuture<HTTPResponse> in
      // 2
      guard let quote = quote else {
        return request.eventLoop.newFailedFuture(error: QuoteAPIError.notFound)
      }

      // 3
      return repository.deleteOne(by: id).thenThrowing {
        let body = try HTTPBody(json: quote, pretty: true)
        return HTTPResponse(status: .ok, body: body)
      }
    }
}

This is what you just wrote:

  1. As with getQuote(by:for:) , you attempt to create a UUID from the supplied id .
  2. You try to retrieve the matching quote from the repository and if it doesn’t exist, return an error as an EventLoopFuture because then doesn’t allow throwing.
  3. Finally, you remove the quote and return the deleted quote as JSON.

Note : If you had used map instead of then , the result would be an EventLoopFuture<EventLoopFuture<HTTPResponse>> . Therefore, then is used to simplify the result to EventLoopFuture<HTTPResponse> .

Routing the Delete Requests

The final step for you is to create a route for the DELETE methods.

In the respond(to:) method, add this code above the default clause of the switch statement:

case .DELETE:
  // 1
  let components = request.head.uri.split(separator: "/", maxSplits: .max, omittingEmptySubsequences: true)
  
  // 2
  guard components.count == 1,
    let component = components.first else {
      return request.eventLoop.newFailedFuture(error: QuoteAPIError.notFound)
  }
  
  // 3
  let id = String(component)
  return deleteQuote(by: id, for: request)

This piece of code might look very familiar to your from the previous change you made to the GET case. Breaking this down:

  1. You split the path into its components.
  2. Check to ensure that there is exactly one component. That single component is used as the identifier of the removed quote.
  3. Finally, you send the delete request to the route that will return the response for the user.

Because there is no fully fledged web framework such as Kitura or Vapor in use here, the respond(to:) methods needs to route these requests manually.

To test this, restart the app, add some quotes and then use RESTed to call http://localhost:8080/uuid with a DELETE request, replacing uuid with the id of a quote you have added.

Screen-Shot-2019-02-25-at-22.50.29.png

Ta-da! Who would’ve imagined that erasing history is this easy? Take that, historians! :]

Where to Go From Here?

To learn more about SwiftNIO, have a look at our own tutorial on building a TCP server with SwiftNIO that dives deeper into event loops and networking. If you’re willing to dive deeper into the framework yourself, check out Apple’s SwiftNIO documentation on GitHub.

I hope this tutorial was useful for you. Feel free to join the discussion below!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK