Einführung in “Swift on Server”

Vapor Code

Warum Swift?

Um die Swift on Server Website zu zitieren:

The goal of the Swift project is to create the best available language for uses ranging from systems programming, to mobile and desktop apps, scaling up to cloud services.

(Das Ziel des Swift-Projekts ist es, die beste verfügbare Sprache für Anwendungen zu schaffen, die von der Systemprogrammierung über mobile und Desktop-Anwendungen bis hin zu Cloud-Diensten reicht.)

Strikte Typisierung, async/await und Actors sind Schlüsselfunktionen, die die Erstellung und Wartung korrekter Programme erleichtern. Einige Merkmale, die Swift besonders geeignnet für Serveranwendungen machen, sind der geringe Ressourcen-Bedarf in puncto Prozessorlast und Speicher, die schnelle Startzeit und die deterministische Leistung dank ARC und dem Fehlen von JIT.

Die Systemlast im Vergleich zu node.js kann um bis zu 90% geringer sein. Außerdem können iOS-Entwickler*innen, die bereits mit Swift vertraut sind, so auch als Backend-Entwickler*innen fungieren.

Geteilte DTOs

Im Kontext von Client-Server-Anwendungen ergibt sich ein weiterer Vorteil. Code kann zwischen dem Server und der Client-App geteilt werden. Indem wir zum Beispiel die Datentransferobjekte (DTOs) gemeinsam nutzen, machen wir unsere API typsicher. Am einfachsten ist es hierfür, ein separates Swift-Paket zu erstellen, das dann an beiden Stellen importiert werden kann.

Warum Vapor?

Es gibt eine Vielzahl an Frameworks für die Nutzung von Serverside Swift. Vapor ist derzeit das beliebteste. Es baut auf SwiftNIO auf und profitiert daher von Verbesserungen, die von Apple und der Swift Server Group kommen. Die API ist sehr “swifty” und setzt einen Fokus auf Einfachheit und Kombinierbarkeit. Vapor hat eine hervorragende Dokumentation und unterstützt Unit-Tests sowohl im Speicher als auch mit einer Live-Umgebung.

Beispiel

Schauen wir uns ein Beispiel für eine einfache Vapor-Anwendung mit einem einzelnen Endpunkt an.

import Vapor
 
let app = try Application(.detect())
defer { app.shutdown() }

app.get("hello") { req in
    return "Hello, world."
}

try app.run()

Das ist alles.

Kommandozeile

Um ein neues Projekt zu erstellen, können wir das Kommandozeilentool vapor verwenden, das über Homebrew installiert werden kann.

vapor new Demo

Das Tool führt uns durch den Einrichtungsprozess und ermöglicht uns die Auswahl zusätzlicher Frameworks wie Fluent, auf das wir später noch zu sprechen kommen.

Content

Vapors Content-API basiert auf Codable. Die Konformität eines structs mit Content erlaubt es, sie bei der Dekodierung von Anfragen oder der Kodierung von Antworten zu verwenden.

struct User: Content {
    let username: String
    let email: String
}

Zusätzlich zu JSON unterstützt Vapor Multipart, URL-Encoded Form, Plaintext und HTML. Apropos HTML: Vapor hat sogar eine eigene Template-Sprache mit einer von Swift inspirierten Syntax namens Leaf und es gibt bereits auf Vapor basierende Content Management Systeme. Das soll aber nicht das Thema dieses Blogposts sein.

Routen

Routen definieren, wie HTTP-Anfragen behandelt werden. Wie im obigen Beispiel können wir eine Route hinzufügen, indem wir einen Code-Block übergeben.

app.get("hello") { req in
    return "Hello, world."
}

Parameter werden durch ein vorangestelltes : markiert.

app.get("hello", ":name") { req -> String in
    let name = req.parameters.get("name")!
    return "Hello, \(name)!"
}

Darüber hinaus unterstützt das Routing anything (*) und catchall (**) Pfadkomponenten.

Über den content können wir auf den Body einer Anfrage zugreifen.

let user = try req.content.decode(User.self)

Die Stärke von Vapors Routing-Ansatz liegt in der Kombinierbarkeit. Zum Beispiel können wir Routen unter einem gemeinsamen Präfix gruppieren.

let users = app.grouped("users")

users.get { req in /*...*/ }
users.post { req in /*...*/ }
users.delete(":id") { req in /*...*/ }

Gruppen können auch dazu verwendet werden, um Middleware für Rate-Limitierung oder Authentifizierung hinzuzufügen. Mehr dazu später.

Fluent

Fluent ist ein Framework für Objektrelationale Abbildung. Es unterstützt PostgreSQL, SQLite, MySQL, MongoDB und mehr.

Model

Objekte werden durch normale Swift-Klassen beschrieben, die mit Model konform sind. Die Schlüssel werden über Property Wrapper definiert.

final class User: Model {
    static let schema = "users"
    
    @ID(key: .id)
    var id: UUID?
    
    @Field(key: "username")
    var username: String
    
    @Field(key: "email")
    var email: String
}

Eltern-/Kind- oder Geschwisterbeziehungen können direkt in der Klasse definiert werden, was einen einfacheren Zugriff ermöglicht.

final class Planet: Model {
    // ...
    
    @Parent(key: "star_id")
    var star: Star
}

final class Star: Model {
    // ...
    
    @Children(for: \.$star)
    var planets: [Planet]
}

Wir könnten das Objekt auch an Content adaptieren, um es direkt in unserer API zu verwenden, aber da wir ein gemeinsames DTO-Paket nutzen, ist das in unserem Fall nicht notwendig.

Migration

Migrationen kann man sich wie Versionskontrollen für die Datenbank vorstellen. Innerhalb einer Migration können wir ein oder mehrere Schemata einrichten oder ändern. Darüber hinaus implementieren wir eine Umkehrfunktion, um die Änderungen rückgängig machen zu können.

struct Create: AsyncMigration {
    func prepare(on database: Database) async throws {
        try await database.schema("users")
            .id()
            .field("username", .string, .required)
            .field("email", .string, .required)
            .unique(on: "username")
            .create()
    }
    
    func revert(on database: Database) async throws {
        try await database.schema("users").delete()
    }
}

Anschließend fügen wir diese Migration zur Migrationsliste der Anwendung hinzu (app.migrations.add), wodurch wir sie über die Befehlszeile aufrufen können.

vapor run migrate

Es ist wichtig, dies nach jeder Schema-Änderung zu wiederholen. Alternativ kann die Migration auch automatisch beim Start durchgeführt werden mittels des --auto-migrate Flags oder try await app.autoMigrate().

Nutzung von Objekten

Um der Datenbank etwas hinzuzufügen, erstellen wir ein Objekt unserer Klasse und rufen die create(on:) Funktion unter Angabe einer Datenbank auf.

let user = try User(
    username: dto.username,
    email: dto.email
)
try await user.create(on: req.db)

Query

Die Query-API von Fluent erinnert an die Collection-API von Swift. Um etwa nach einem Benutzer oder einer Benutzerin mit einem bestimmten Namen zu suchen, können wir die folgende Abfrage durchführen.

let user = try await User.query(on: req.db)
    .filter(\.$username == "myuser")
    .first()

Kennen wir bereits die ID des Objektes, können wir direkt die find Methode nutzen.

let user = try await User.find(id, on: req.db)

Fluent unterstützt eine Vielzahl an Operatoren für die Filter-Funktion. Wir können zum Beispiel nach allen Benutzer*innen filtern, deren Name mit einem “m” beginnt.

let users = try await User.query(on: req.db)
    .filter(\.$username =~ "m")
    .all()

Fluent unterstützt außerdem Joins, Chunking, Aggregate und mehr.

Middleware

Middleware kann zwischen dem Client und einem Vapor Route-Handler eingefügt werden. Eine Middleware kann Operationen an eingehenden Anfragen und ausgehenden Antworten durchführen. Typische Anwendungsfälle für eine Middleware sind Rate-Limiting oder Benutzerauthentifizierung.

Authentifizierung

Fluent kommt mit einer eingebauten Middleware, um die Benutzerauthentifizierung zu handhaben. Um sie zu verwenden, konformen wir die User-Klasse zu ModelAuthenticatable, indem wir KeyPaths zum Benutzernamen und dem Passwort-Hash bereitstellen und den Körper der Funktion verify(password:) implementieren. Für die Kryptographie verwenden wir das mit Vapor mitgelieferte Bcrypt.

final class User: Model {
    // ...
    
    @Field(key: "password_hash")
    var passwordHash: String
}

extension User: ModelAuthenticatable {
    static let usernameKey = \User.$username
    static let passwordHashKey = \User.$passwordHash
    
    func verify(password: String) throws -> Bool {
        try Bcrypt.verify(password, created: self.passwordHash)
    }
}

Beim Anlegen von neuen Nutzer*innen speichern wir den Passwort-Hash im Objekt. Bcrypt erledigt das Salting automatisch, sodass wir uns darüber keine Gedanken machen müssen.

let user = try User(
    username: dto.username,
    email: dto.email,
    passwordHash: Bcrypt.hash(dto.password)
)
try await user.create(on: req.db)

Nun können wir die Middleware in unsere Route einfügen.

let passwordProtected = routes.grouped(User.authenticator())

passwordProtected.post("login") { req -> String in
    let user = try req.auth.require(User.self)
    // ...
}

Wenn ein Client eine Anfrage erstellt (und die richtigen Anmeldedaten angibt), können wir über auth auf das Benutzerobjekt zugreifen.

let user = try req.auth.require(User.self)

Vapor unterstützt außerdem Benutzer-Tokens und JWTs.

In der Praxis

Um Vapor ausgiebig zu testen, habe ich ein Demoprojekt erstellt. Ich entschied mich für die Idee eines Sitzplatzreservierungstools. Es ermöglicht Benutzer*innen, sich anzumelden und Standorte zu erstellen oder ihnen beizutreten, wo sie dann einen Platz für einen oder mehrere Tage reservieren oder die Reservierungen ihrer Kolleg*innen einsehen können. Der Server bietet insgesamt 19 API-Endpunkte, verwendet sowohl Benutzer/Passwort- als auch Benutzer-Token-Authentifizierung, führt Rate-Limiting durch, hat individuelle Abfragevalidierungen und speichert die Daten in einer SQLite-Datenbank. Das gesamte Projekt umfasst dabei nur etwas mehr als 1000 Codezeilen.

Darüber hinaus habe ich eine Client-App für iOS, iPadOS und macOS entwickelt. Die gemeinsame Nutzung der DTOs zwischen dem Server und der Client-App erwies sich als sehr praktisch. Dank der Leistungsfähigkeit von Erweiterungen in Swift konnte ich die DTOs an die jeweilige Situation anpassen, beispielsweise an das Content-Protokoll auf dem Server.

extension DTO.User: Content {}

Da die DTOs bereits Codable sind, musste ich nichts weiter hinzufügen.

Fazit

Als iOS-Entwickler fand ich Vapor erstaunlich einfach zu erlernen. Die erstklassige Dokumentation mit vielen Beispielen ist sehr hilfreich. Das Datenbank-Framework und die eingebaute Unterstützung für Authentifizierung machen auch komplizierte Anwendungen mit überschaubarem Aufwand möglich.

Meine Empfehlung ist daher, Vapor mit uns zusammen auszuprobieren, wenn man ein Backend für eine neue mobile App erstellt. Aber auch für größere Backend-Anwendungen kann Vapor dank seiner sehr exzellenten Performance-Eigenschaften eine gute Wahl sein.

App Platzda
Screenshot von App Platzda
Melvin Gundlach

Autor

Melvin Gundlach
Advanced Developer