MoreRSS

site iconKevinzhow | 周楷雯 

日语学习APP开发者,PNChart作者,INTP-T,日本越谷。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Kevinzhow | 周楷雯 的 RSS 预览

没有光纤的日子怎么上网?自制 Home WI-FI!

2024-10-10 18:47:53

这次搬家是彻底失算了,没想到日本 2022 年的新筑 Terrace (日语叫 メゾンネット,中文可以理解为联排)竟然没有光纤,这就导致在各家装宽带的网页上,公寓和一户建都不太能准确描述这个地方。

各种波折导致我没能顺利搬迁之前的宽带,也没能很快装上新的宽带。这也是为什么我会写这篇文章,主要是抒发下没有宽带的苦闷。

为什么不用运营商的 Home WI-FI

日本很多运营商都提供了一种叫做 Home Wi-Fi 的家庭热点,原理和手机热点一样,都是通过一张 SIM 卡连接到移动网络,然后再分享出来。

Speed Wi-Fi HOME 5G L13

但与手机不同的是,Home WI-FI 这种设备把技能点都点在了下载上。

Speed Wi-Fi HOME 5G L13

比如在我这里通过 5G 连接 Au 的线路,下载可以到 200M,但上传可能只有 10M - 20M,同样的卡切换成我的 Pixel 7a 之后,下载是 120M 左右,上传是 60 - 80M. 下图可以看到 Pixel 7a 的上传性能要好很多。

Pixel 7a’s Exynos 5300

有了这个数据的对比后,我决定把 Android 手机的网通过 RJ45 网线提供给路由器上网。

为什么不用手机热点?

在日本 Android 手机只能分享 2.4G 的 WI-FI,这导致 WI-FI 的速度和稳定性,带机量都相当有限。

自制 Home WI-FI 的简单方式

具备下面 3 个物品就可以方便的自制一个 Home WI-FI 了

  1. 流量卡
  2. 支持以太网共享的 Android 手机
  3. USB to RJ45 的转接设备
  4. 一个路由器(应该都有吧!)

流量卡

在日本的话 Povo 和乐天这两个放题卡都是不错的选择,需要注意的是自己所在的区域是否有对应的 5G 信号支持,5G 通常可以提供一个 20ms 左右的低延迟体验,即使人多的时候速度不会降的离谱。

Android 手机

虽然 小米,三星,Google Pixel 的手机大都支持以太网网络共享,但这一点还是需要自己先确认下。

USB to RJ45

你可以选择一个带有 Type C 供电的转接头,比如我购买的支持 PD 电源输入的这款

image 3.png

除了专门买个转接头外,也可以用自己手头带有 RJ45 接口的扩展坞,但扩展坞和手机的兼容性可能不太好,比如我手里的 Anker 扩展坞可以和小米手机一起用,但 Pixel 使用则会一直掉线。

在购买前需要确认转接设备和 Android 手机的兼容性,一般如果商家写了支持 Android 设备那么通常没问题。

路由器

正常的家用 WI-FI 路由器即可 :)

使用方式

IMG_8949.jpg
  1. Android 手机插好自己的流量卡
  2. 将 RJ45 转接头插到手机上
  3. 在 Android 手机的热点中打开「以太网网络共享」这个选项,如果转接头不兼容,那么这个会是灰色的无法开启
  4. 用一根网线连接 Android 手机转出来的 RJ45 口和路由器的 WAN 口
  5. 确保路由器的 WAN 口上网模式是「IP 动态分配」

好了,可以享受网络了!我用了一个星期后感觉还是挺稳定的,不过还是祝你早日装上宽带!

Swift on Server Tour 6 关联 User 和 Post

2023-10-30 13:28:30

在上一章中我们创建了 UserModel,并构建了 UserController,但尚未构建起 UserPost 之间的关系。在这一章中,我们将在 Vapor 中完成一对多的关系构建。

本章代码可以在 Github 中找到。

修改 Post 的数据结构

要修 Post 表结构,我们首先需要创建一个新的 Migration 文件。

创建 Sources/App/Migrations/3_AddUserIDToPost.swift 文件,添加如下代码:

import Fluent
struct AddUserIDToPost: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema(Post.schema)
.field("user_id", .uuid, .references(User.schema, "id"))
.update()
}
func revert(on database: Database) async throws {
try await database.schema(Post.schema)
.deleteField("user_id")
.update()
}
}

这种方式将会给 Post 表添加一个可为空的 user_id 字段,虽然这种方式简单直接,但同时也意味着,我们容忍了 Post 可能不属于任何用户的情况。

prepare 方法中,我们使用 references 方法来创建了一个外键约束,这个外键将会引用 User 表中的 id 字段,确保了 user_id 在数据库层面的正确性,避免我们意外插入一个不存在的 user_id

revert 方法中,我们使用 deleteField 方法来删除 user_id 字段,这个方法会同时删除它的外键约束。

随后,我们应当修改 configure.swift 将这条 Migration 添加到其中

app.migrations.add([CreatePost(), CreateUser(), AddUserIDToPost()])

现在,运行 vapor run migrate 即可进行数据库的迁移。

修改 Post Model

完成了数据库 Schema 的修改后,我们需要进一步修改 Post Model

编辑 Sources/App/Models/Post.swift 文件如下:

final class Post: Model, Content {
static let schema = "posts"
@ID(key: .id)
var id: UUID?
@OptionalParent(key: "user_id")
var user: User?
@Field(key: "content")
var content: String
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
init() { }
init(id: UUID? = nil, content: String) {
self.id = id
self.content = content
}
}

因为我们在给 Post 的 Schema 添加 user_id 字段时允许其为空,因此在 Post Model 中,我们使用 @OptionalParent 来表明 user 是不一定存在的。

与此同时,@OptionalParent(key: "user_id") 表明了 user_id 和 User Model @ID 之间的对应关系,这与 Schema 中的 .field("user_id", .uuid, .references(User.schema, "id")) 形成对应关系。

修改 User Model

import Vapor
import Fluent
final class User: Model, Content {
static let schema = "users"
@ID(key: .id)
var id: UUID?
@Field(key: "username")
var username: String
@Field(key: "password_hash")
var passwordHash: String
@Children(for: \.$user)
var posts: [Post]
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
init() {}
init(id: UUID? = nil, username: String, passwordHash: String) {
self.id = id
self.username = username
self.passwordHash = passwordHash
}
}

在 User Model 中,@Children(for: \.$user) 表明了 User 和 Post 之间的一对多关系,同时也说明了,这种关系是通过 Post Model 中的 user 属性来进行关联的。

现在,我们有两种方式可以创建 Post 了,一种是通过 Post Model 的 user 属性:

let user = User(username: "hellouser", passwordHash: try await app.password.async.hash("123456"))
try await user.save(on: app.db)
let post = Post(content: "Hello, World!")
post.$user.id = try user.requiredID()

另一种是通过 User Model 的 posts 属性

let user = User(username: "hellouser", passwordHash: try await app.password.async.hash("123456"))
try await user.save(on: app.db)
let post = Post(content: "Hello, World!")
user.$posts.create(post, on: app.db)

不管采用哪一种,我们都需要在保存 Post 前知道 user_id

使用 BasicAuthorization 进行用户验证

在 PostController 中,我们需要修改 create 方法,使其能够接收 user_id 参数,并将其与 Post 关联起来。

那么如何将 user_id 传入到 create 方法中呢?

直接让客户端提供 user_id 参数肯定是不可以的,因为这样的话,客户端只需要伪造 user_id 就可以假装成别的用户发布,这显然是不安全的。

我们暂且通过最简单的 BasicAuthorization 来解决这个问题,BasicAuthorization 需要客户端提供 username 和 password 两个字段,并将其进行 Base64 编码后放在 HTTP Header 中:

计算格式如下

base64(username:password)

以上面的用户信息 happyuser:123456 为例,计算后的 Header 结果如下:

Authorization: Basic aGFwcHl1c2VyOjEyMzQ1Ng==

服务器端验证用户名和密码后,才会允许客户端创建对应用户的 Post。

BasicAuthorization 作为一种业界通用实践,Vapor 已经提供了 Model Authenticatable 来帮助我们完成这个功能

修改 Sources/App/Models/User.swift 在底部增加如下代码:

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)
}
}

usernameKeypasswordHashKey 分别表明了 User Model 中的哪两个属性用于存储用户名和密码。当客户端提供用户名和密码时,Vapor 会自动将其与 User Model 中的 usernameKeypasswordHashKey 进行对应,从而完成用户验证。

接下来我们需要确保 Post 在创建前,用户已经通过了验证,这可以通过在 PostController 的路由中,加入权限验证相关的 Middleware 来完成,Vapor 已经提供了 User.authenticator() 来帮助我们完成 BasicAuthorization 验证的功能。

修改 Sources/App/Controllers/PostController.swift 中 boot 代码如下

func boot(routes: RoutesBuilder) throws {
routes.group("posts") { posts in
posts.get(use: index)
posts.grouped(User.authenticator()).post(use: create)
}
}

posts 路由组中,当客户端请求 posts 路由组中的 post 方法前,User.authenticator() 会进行 BasicAuthorization 验证,如果没有通过验证,Vapor 会返回 401 错误。

修改 PostController

修改 Sources/App/Controllers/PostController.swift 中的 create 方法如下:

func create(req: Request) async throws -> Post {
let user = try req.auth.require(User.self)
let postData = try req.content.decode(Post.CreateDTO.self)
let post = Post(content: postData.content)
post.$user.id = try user.requireID()
try await post.create(on: req.db)
return post
}

create 方法中,我们首先通过 req.auth.require(User.self) 获取到了已经通过验证的用户,然后通过 req.content.decode(Post.CreateDTO.self) 获取到了客户端传入的 Post 数据,最后通过 post.$user.id = try user.requireID() 将 Post 和 User 关联起来。

完善 Post 的单元测试

修改 Tests/AppTests/PostTests.swift 如下:

@testable import App
import XCTVapor
final class PostTests: XCTestCase {
var app: Application!
override func setUp() async throws {
app = Application(.testing)
try configure(app)
try await app.autoRevert()
try await app.autoMigrate()
}
override func tearDown() async throws {
app.shutdown()
}
func testCreatePost() async throws {
let user = User(username: "hellouser", passwordHash: try await app.password.async.hash("123456"))
try await user.save(on: app.db)
let postDTO = Post.CreateDTO(content: "Post created from test")
try app.test(.POST, "posts", beforeRequest: { req in
try req.content.encode(postDTO)
req.headers.basicAuthorization = BasicAuthorization(username: user.username, password: "123456")
}, afterResponse: { res in
XCTAssertEqual(res.status, .ok)
let post = try res.content.decode(Post.self)
XCTAssertEqual(postDTO.content, post.content)
XCTAssertEqual(try user.requireID(), post.$user.id)
})
}
}

testCreatePost 中,我们首先创建了一个用户,然后创建了一个 Post DTO,接着通过 app.test 方法,模拟了客户端的请求,最后验证了 Post 的 User 来判断是否创建成功。

总结

在这一章中,我们学习了如何在 Vapor 中构建一对多的关系,同时也学习了如何使用 BasicAuthorization 来进行用户验证。但这种方式虽然简单,但也有很多缺点,在下一章中,我们会学习使用 BearerAuthentication 来对用户进行验证,并进一步修复 User Model 的安全隐患。

本章代码可以在 Github 中找到。

Swift on Server Tour 5 创建 Users

2023-10-21 13:51:11

在本章中我们将在数据库中创建 User 表,用于存储用户信息,并理解如何将 Post 表和 User 关联起来,以便我们可以知道每篇微博是由哪个用户编写的。

本章代码可以在 Github 中找到。

设计 User 表的数据结构

一个基础的 User 表应该包含以下字段

  • id:主键,用于唯一标识一个用户
  • username:用户名,用于登录
  • password_hash:密码,用于登录,存储的应该是加密后的密码
  • createdAt:创建时间,用于记录用户创建时间

如果我们插入了一个假数据 happyuser,那么我们的 User 表应该是这样的:

id username password_hash createdAt
0 happyuser encrypted text 2023-1-1

那么如何关联 Post 表呢?

数据库的关系型设计

在关系性数据库中,表关系被总结为三种:

  • 一对一(One-to-One)
  • 一对多(One-to-Many)
  • 多对多(Many-to-Many)

我们的 User 表和 Post 表的关系是一对多,即一个用户可以有多篇微博,而一篇微博只能属于一个用户。

一对多关系的设计

在数据库中,我们可以通过在 Post 表中添加一个 userId 字段来表示这种关系,这个字段用于存储 User 表中的 id,即 Post 表中的每一行都会有一个 userId 字段,用于表示这篇微博是由哪个用户编写的。

修改之前的 Post 表,添加 userId 字段,表示如下:

id userId content createdAt
0 0 这是第一篇博文 2023-1-1
1 0 在 LONCAFE 写代码感觉不错呢! 2023/7/9 14:44

当我们需要查询 happyuser 的所有微博时,只需要在 Post 表中查询 userId = 0 的所有行即可。

创建 User Model

Sources/App/Models 目录下创建 User.swift 文件,添加如下代码:

import Vapor
import Fluent
final class User: Model, Content {
static let schema = "users"
@ID(key: .id)
var id: UUID?
@Field(key: "username")
var username: String
@Field(key: "password_hash")
var passwordHash: String
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
init() {}
init(id: UUID? = nil, username: String, passwordHash: String) {
self.id = id
self.username = username
self.passwordHash = passwordHash
}
}
extension User {
struct CreateDTO: Content {
let username: String
let password: String
}
}

接下来,我们需要添加 User 表的 Migration 文件,用于在数据库中创建 User 表。

Sources/App/Migrations 目录下创建 2_CreateUser.swift 文件,添加如下代码:

import Fluent
struct CreateUser: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema(User.schema)
.id()
.field("username", .string, .required)
.field("password_hash", .string, .required)
.field("created_at", .datetime)
.create()
}
func revert(on database: Database) async throws {
try await database.schema(User.schema).delete()
}
}

configure.swift 文件中修改 app.migrations.add,用于注册 User Migration:

app.passwords.use(.bcrypt) // Bcrypt 是一种密码加密算法,可以确保多次加密后的密码都是不同的,但是可以通过原始密码验证
app.migrations.add([CreatePost(), CreateUser()])

创建 User Controller

Sources/App/Controllers 目录下创建 UserController.swift 文件,添加如下代码:

import Fluent
import Vapor
struct UserController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
routes.group("users") { posts in
posts.get(use: index)
posts.post(use: create)
}
}
func create(req: Request) async throws -> User {
let postData = try req.content.decode(User.CreateDTO.self)
let user = User(username: postData.username, passwordHash: try await req.password.async.hash(postData.password))
try await user.create(on: req.db)
return user
}
func index(req: Request) async throws -> [User] {
let users = try await User.query(on: req.db).all()
return users
}
}

configure.swift 文件中注册 UserController

try app.register(collection: UserController())

创建 User 的 XCTest 测试用例

Tests/AppTests 目录下创建 UserTests.swift 文件,添加如下代码:

@testable import App
import XCTVapor
final class UserTests: XCTestCase {
var app: Application!
override func setUp() async throws {
app = Application(.testing)
try configure(app)
try await app.autoRevert()
try await app.autoMigrate()
}
override func tearDown() async throws {
app.shutdown()
}
func testCreateUser() async throws {
let userData = User.CreateDTO(username: "happyuser", password: "123456")
try app.test(.POST, "users", beforeRequest: { req in
try req.content.encode(userData)
}, afterResponse: { res in
XCTAssertEqual(res.status, .ok)
let user = try res.content.decode(User.self)
XCTAssertEqual(user.username, userData.username)
XCTAssertTrue(try app.password.verify("123456", created: user.passwordHash))
})
}
func testGetUsers() async throws {
let user = User(username: "hellouser", passwordHash: try await app.password.async.hash("123456"))
try await user.save(on: app.db)
try app.test(.GET, "users", afterResponse: { res in
XCTAssertEqual(res.status, .ok)
let users = try res.content.decode([User].self)
XCTAssertEqual(users.count, 1)
XCTAssertEqual(users[0].username, user.username)
})
}
}

运行测试用例

在终端中运行 swift test 命令,顺利的话,可以看到测试用例全部通过。

总结

在本章中,我们学习了如何在数据库中创建 User 表,以及如何将 Post 表和 User 表关联起来,但我们尚未真正在数据库中关联这两个表,我们将在下一章中完成这个功能。

本章代码可以在 Github 中找到。

Swift on Server Tour 4 构建 Post Controller

2023-08-06 15:57:46

在本章中,我们将为 Post 创建一个 Controller,将 Route 和 Route Handler 都移动到这里,保持项目代码的整洁

Table of Contents

Controller 中的 Routing

在上一章中,下面两个和 Post 相关的 Route 和 Handler 都写在了 Sources/App/configure.swift

  • app.post("posts")
  • app.get("posts")

现在我们创建 Sources/App/Controllers/PostController.swift 文件,并将代码移动到这里

import Fluent
import Vapor
struct PostController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
routes.group("posts") { posts in
posts.get(use: index)
posts.post(use: create)
}
}
func create(req: Request) async throws -> Post {
let postData = try req.content.decode(Post.CreateDTO.self)
let post = Post(content: postData.content)
try await post.create(on: req.db)
return post
}
func index(req: Request) async throws -> [Post] {
let posts = try await Post.query(on: req.db).all()
return posts
}
}

在 boot 函数中,我们通过 RoutesBuilder 定义了 route 和 handler 之间的关系。

注册 Controller

现在我们需要将 Controller 注册到 Application 中,让 Application 知道有哪些路由需要被处理

修改 Sources/App/configure.swift

import Fluent
import FluentPostgresDriver
import Vapor
public func configure(_ app: Application) throws {
app.databases.use(.postgres(configuration: SQLPostgresConfiguration(
hostname: Environment.get("DATABASE_HOST") ?? "localhost",
port: Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? SQLPostgresConfiguration.ianaPortNumber,
username: Environment.get("DATABASE_USERNAME") ?? "vapor_username",
password: Environment.get("DATABASE_PASSWORD") ?? "vapor_password",
database: Environment.get("DATABASE_NAME") ?? "vapor_database",
tls: .prefer(try .init(configuration: .clientDefault)))
), as: .psql)
app.migrations.add([CreatePost()])
try app.register(collection: PostController())
}

完成添加之后,可以使用 swift run App routes 来打印 App 内生效的 routes

+------+--------+
| GET  | /      |
+------+--------+
| GET  | /posts |
+------+--------+
| POST | /posts |
+------+--------+

测试 PostTests

我们的项目经过了一遍较大的结构改动,但如果逻辑没有错误,我们之前写的 PostTests 应该仍然是可以通过的,换句话说,只要 PostTests 通过了, 我们就无需过于担心功能是否可以正常运行。

运行测试

swift test

测试结果通过

Test Case '-[AppTests.PostTests testCreatePost]' passed (0.230 seconds).

Unit Test 又一次验证了它的必要性。

下章预告

在下一章中,我们将会添加 User 并关联 User 和 Post

Swift on Server Tour 3 构建 Post 的 API

2023-07-23 22:21:58

在本章中,我们将为 Post 配置 API,使得我们可以通过 HTTP 请求来完成 Post 的创建。同时,我们将引入 Environment 来为我们的服务器区分 Development 和 Testing 环境。

Table of Contents

API 的基础理念

在我们编程的时候,经常会使用第三方提供的接口来完成一个特定任务,使得原本复杂的任务,只需要调用一个 API 就可以完成,这便是 API 最基础的理念,将特定的任务进行封装。

API 几乎无处不在,以用户使用 App 发微博为例,可以表示为下面的流程

User -> UI -> Client -> HTTP -> Server -> Fluent -> Database

用户操作 UI 编写内容,内容被记录在客户端 App,客户端通过 HTTP 请求发送到服务器,服务器接收到请求后,这些请求通过 Fluent 转化成 SQL 语句,最终在数据库中完成数据更新。

  • UI 提供了「用户」和「客户端」之间的 API

  • HTTP 接口提供了「客户端」和「服务器」之间的 API

  • Fluent 提供了「服务器」和「数据库」之间的 API

在本章中,我们将要设计的就是 Client -> Server 之间的 HTTP API.

创建 Post 的 HTTP API

在第一章中,我们曾提供了一个非常基础的 "It works!" 的 API,了解了一个 HTTP 请求所需的基本参数。

现在,我们希望有一个可以创建 Post 的 API,需求如下

  • 使用 POST 请求
  • 路径为 /posts
  • 请求内容为 JSON,内容包含一个 content 字段来表示 Post 的内容

如果客户端按照这个标准发来一个 HTTP 请求,那么内容看起来就是这样的

POST /posts HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/json
Content-Length: 49

{
    "content": "First post from HTTP request"
}

Hint

需要客户端填写的是 POST /posts 和 JSON 部分的内容,其他如Host, Content-Type, Content-Length 均会在请求时自动生成。

首先,定义一个叫做 Post.CreateDTO 的 DTO struct 来表示客户端发来的 JSON 内容格式

extension Post {
struct CreateDTO {
let content: String
}
}

Hint

DTO,全称 Data Transfer Object,是一种常见的设计模式,用于在服务之间传输数据,可以灵活定义字段,便于封装和安全性验证,亦可减少流量的消耗。

接下来编辑 Sources/App/configure.swift 加入对 POST /posts 的处理

import Fluent
import FluentPostgresDriver
import Vapor
public func configure(_ app: Application) throws {
app.databases.use(.postgres(configuration: SQLPostgresConfiguration(
hostname: "localhost",
port: 5432,
username: "vapor_username",
password: "vapor_password",
database: "vapor_database",
tls: .prefer(try .init(configuration: .clientDefault)))
), as: .psql)
app.migrations.add([CreatePost()])
app.post("posts") { req async throws -> Post in
let postData = try req.content.decode(Post.CreateDTO.self)
let post = Post(content: postData.content)
try await post.create(on: req.db)
return post
}
}
  • req.content.decode(Post.CreateDTO.self) 表示将请求中 body 的内容解码为 Post.CreateDTO

修改 Sources/App/Models/Post.swift 使 Post 支持被编码为 JSON

import Vapor
import Fluent
final class Post: Model, Content {
// 数据库中的表名
static let schema = "posts"
// 唯一性标识符
@ID(key: .id)
var id: UUID?
// 内容
@Field(key: "content")
var content: String
// 创建时间
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
init() { }
init(id: UUID? = nil, content: String) {
self.id = id
self.content = content
}
}
extension Post {
struct CreateDTO: Content {
let content: String
}
}
  • 给 Post 增加 Content 协议,使之具备 Codable 的特性,能够被编码成 JSON 返回给用户
  • 增加了一个Post.CreateDTO 的结构体,同样遵循了 Content 协议,使之可以用来解码用户发来的 JSON

测试 API

现在,我们可以修改 AppTests/PostTests.swift 来测试这个 API 是否能够跑通了

@testable import App
import XCTVapor
final class PostTests: XCTestCase {
func testCreatePost() async throws {
let app = Application(.testing)
defer { app.shutdown() }
try configure(app)
try await app.autoRevert()
try await app.autoMigrate()
let postDTO = Post.CreateDTO(content: "Post created from test")
try app.test(.POST, "posts", beforeRequest: { req in
try req.content.encode(postDTO)
}, afterResponse: { res in
XCTAssertEqual(res.status, .ok)
let post = try res.content.decode(Post.self)
XCTAssertEqual(postDTO.content, post.content)
})
}
}
  • 使用 app.test(.POST, "posts") 模拟外部 HTTP 请求
  • beforeRequest 可以对请求数据进行修改,我们通过 req.content.encode(postDTO) 将 postDTO 编码到 request 的 body 中,在默认情况下,请求时数据会采用 JSON 编码
  • afterResponse 是请求后从服务器获得的响应,我们首先判断 res.status 是否是 HTTPStatus.ok 即 200
  • XCTAssertEqual(postDTO.content, post.content) 判断服务器上创建的数据是否和发送的一致

运行这段测试,不出意外,将可以看到测试通过的信息

Test Case '-[AppTests.PostTests testCreatePost]' passed (0.269 seconds).

恭喜你!第一个 API 调通了!

使用 cURL 命令进行测试

除了使用 Unit Test 进行 API 测试以外,我们也可以使用 cURL 命令进行测试,以上面创建 Post 的请求为例,我们可以在终端中输入以下命令

curl -i --location 'http://127.0.0.1:8080/posts' \
--header 'Content-Type: application/json' \
--data '{
"content": "First post from HTTP request"
}'

Hint

通过 -i 参数,我们要求 curl 打印出完整的 HTTP 响应信息

回车后,正常情况下会响应如下内容

HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 121
connection: keep-alive
date: Sun, 23 Jul 2023 13:22:20 GMT
{"content":"First post from HTTP request","createdAt":"2023-07-23T13:22:20Z","id":"1C9A6A1D-98B8-4871-8DE0-BDF011845ADA"}%

通过 cURL 我们可以很方便的观察 HTTP 信息的构成,通过这则信息,我们也可以更好的理解 Unit Test 中 XCTAssertEqual(res.status, .ok)200 OK 的对应关系。

使用 Postman 进行测试

Postman 是一款非常流行的 API 测试软件,不仅可以方便的进行参数调试,也可以将编辑好的请求转换成其他软件的请求,比如下图右边转换为 cURL

image.png

列出所有 Post

创建 Post 之后,最迫切的期望就是列出所有的 Post,现在我们可以编辑 Sources/App/configure.swift 来加入 GET /posts 的支持

import Fluent
import FluentPostgresDriver
import Vapor
public func configure(_ app: Application) throws {
...
app.get("posts") { req async throws -> [Post] in
let posts = try await Post.query(on: req.db).all()
return posts
}
}
  • Post.query(on: req.db).all() 将获取数据库中所有的 Post.

现在,如果你使用浏览器访问 http://127.0.0.1:8080/posts 将可以看到所有存储在数据库里的 posts

image.png

使用 Postman 来查看会更方便一些

image.png

使用 Environment 区分服务器环境

到目前为止,我们并没有区分服务器的开发环境和测试环境,这导致每次运行单元测试的时候,都会将我们之前在开发环境里创建的数据清除掉,这显然会给我们带来很多麻烦。

我们的数据库连接信息定义在 Sources/App/configure.swift 之中,这意味着如果我们可以让 App 启动时动态的配置数据库的连接信息,分别连接「开发环境数据库」和「测试环境数据库」,就可以解决我们的问题。

在 Vapor 中,通过 Environment 我们可以轻松的将他们分开。

回顾 Sources/App/main.swift,我们使用 let app = Application() 启动我们的 App,但实际上这段代码隐藏了一些细节,它包含了 Environment 的默认值 Environment.development

因此更详尽的代码应该是 let app = Application(.development)

import Vapor
import Fluent
import FluentPostgresDriver
let app = Application(.development)
app.http.server.configuration.port = 8080
defer { app.shutdown() }
app.get { req async in
"It works!"
}
try configure(app)
try app.run()
  • Application() 在启动时,接受 Environment 变量,并读取与 Environment 对应的 .env 文件
  • 在上面的 Application(.development) 代码中,Application 会读取 .env.developemnt

Hint

Vapor 的服务器启动时,默认是 development 环境,因此会从服务器启动目录的 .env.development 中读取环境变量,如果你使用的 Xcode,请记得修改 Scheme 中的 Working Directory 为项目根目录

修改编辑项目根目录中的.env.development 配置开发环境变量

DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USERNAME=vapor_username
DATABASE_PASSWORD=vapor_password
DATABASE_NAME=vapor_database

修改 Sources/App/configure.swift 改变数据库参数的获取方式

app.databases.use(.postgres(configuration: SQLPostgresConfiguration(
hostname: Environment.get("DATABASE_HOST") ?? "localhost",
port: Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? SQLPostgresConfiguration.ianaPortNumber,
username: Environment.get("DATABASE_USERNAME") ?? "vapor_username",
password: Environment.get("DATABASE_PASSWORD") ?? "vapor_password",
database: Environment.get("DATABASE_NAME") ?? "vapor_database",
tls: .prefer(try .init(configuration: .clientDefault)))
), as: .psql)

现在,我们只解决了 develpment 环境的问题,testing 环境需要一个新的数据库,以及一份对应的 .env.testing

修改 docker-compose.yml 增加 db_test 服务

值得注意的是我们在 volumes 增加了 db_data_test,并将 db_test 的端口设置为了 5442

version: '3.7' # 定义 Docker Compose 文件的版本,此处使用的是版本 3.7
volumes: # 定义卷部分
db_data: # docker 会使用这个键作为名字,自动创建 db_data 卷来存储数据
db_data_test:
services: # 定义服务部分
db: # db 服务配置
image: 'postgres:15-alpine' # 使用 PostgreSQL 15 Alpine 版本的镜像
volumes: # 定义挂载卷
- 'db_data:/var/lib/postgresql/data/pgdata' # 将 db_data 卷挂载到容器的 /var/lib/postgresql/data/pgdata 目录
environment: # 定义环境变量
PGDATA: '/var/lib/postgresql/data/pgdata' # 设置 PGDATA 环境变量为 /var/lib/postgresql/data/pgdata
POSTGRES_USER: 'vapor_username' # 设置 POSTGRES_USER 环境变量为 vapor_username
POSTGRES_PASSWORD: 'vapor_password' # 设置 POSTGRES_PASSWORD 环境变量为 vapor_password
POSTGRES_DB: 'vapor_database' # 设置 POSTGRES_DB 环境变量为 vapor_database
ports: # 定义端口映射,将主机的 5432 端口映射到容器的 5432 端口
- '5432:5432'
db_test:
image: 'postgres:15-alpine'
volumes:
- 'db_data_test:/var/lib/postgresql/data/pgdata'
environment: # 定义环境变量
PGDATA: '/var/lib/postgresql/data/pgdata'
POSTGRES_USER: 'vapor_username'
POSTGRES_PASSWORD: 'vapor_password'
POSTGRES_DB: 'vapor_database'
ports:
- '5442:5432'

创建 .env.testing 写入以下内容

DATABASE_HOST=localhost
DATABASE_PORT=5442
DATABASE_USERNAME=vapor_username
DATABASE_PASSWORD=vapor_password
DATABASE_NAME=vapor_database

启动 db_test

docker-compose up db_test -d

现在,服务器的 development 和 testing 环境的数据库就不会再相互干扰了。

本章节的代码可以在 https://github.com/kevinzhow/swift-on-server-tour/tree/main/3 中找到

下章预告

目前我们 Post 相关的操作都堆在了 configure.swift 中,在下一章中,我们将学习如何将他们移动到 Controller 中,并继续完善我们 Post 的查看与删除。