Rust 🦀 使用 Docker 的 CRUD Rest API 🐳
让我们使用 Rust 创建 CRUD Rest API:
- 没有具体的框架
- Serde 用于序列化和反序列化 JSON
- Postgres(数据库)
- Docker
- Docker Compose
如果您更喜欢视频版本:
所有代码均可在 GitHub 存储库中找到(链接在视频描述中):https://youtu.be/vhNoiBOuW94
🏁 简介
以下是我们要创建的应用程序架构图:
我们将为基本的 CRUD 操作创建五个端点:
- 创造
- 阅读全部
- 阅读一篇
- 更新
- 删除
我们将使用 Postgres 作为数据库,并使用 Docker 和 Docker Compose 来运行应用程序。
我们将使用 Postman 测试端点并使用 Tableplus 检查数据库。
👣 步骤
我们将提供一步一步的指南,以便您可以跟随。
步骤如下:
- 检查先决条件
- 项目创建和依赖项安装
- 对应用程序进行编码
- 使用 Docker 运行 Postgres 数据库
- 使用 Docker Compose 构建并运行应用程序
- 使用 Postman 和 TablePlus 测试应用程序
💡 先决条件
- 已安装 Rust 编译器(版本 1.51+)
- 已安装货物(版本 1.51+)
- docker 安装(版本 20.10+)
- [可选] 安装 VS Code(或任何你喜欢的 IDE)
- [可选] Postman 或任何 API 测试工具
- [可选] Tableplus 或任何数据库客户端
🚀 创建一个新的 Rust 项目
要创建一个新的 Rust 项目,我们将使用 CLI。
cargo new rust-crud-api
进入项目文件夹:
cd rust-crud-api
并使用您喜欢的 IDE 打开项目。如果您使用 VS Code,则可以使用以下命令:
code .
打开所调用的文件Cargo.toml
并添加以下依赖项:
postgres = "0.19"
serde = "1.0"
serde_json = "1.0"
serde_derive = "1.0"
postgres
是 Rust 的 Postgres 驱动程序。serde
是一个用于序列化和反序列化的库。serde_json
是一个特定于 JSON 的库。serde_derive
是一个用于派生序列化和反序列化特征(宏)的库
您的Cargo.toml
文件应如下所示:
[package]
name = "rust-crud-api"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
postgres = "0.19"
serde = "1.0"
serde_json = "1.0"
serde_derive = "1.0"
请注意,包名称可能根据您为项目指定的名称而有所不同。
您的项目现在应该如下所示:
我们现在准备对应用程序进行编码。
👩💻编写应用程序代码
我们将一步一步来:
- 导入依赖项。
- 创建模型(具有 ID、姓名和电子邮件的用户)并添加常量。
- 主要功能:数据库连接和TCP服务器。
- 实用函数:set_database、get_id、get_user_request_body。
- 在函数中创建路由(端点)。
- 创建实用函数。
- 创建控制器。
对于这个项目,我们将把所有内容编写在一个约 200 行代码的文件中。
这不是最佳实践,但它将帮助我们专注于 Rust 代码,而不是项目结构。
所有代码均可在GitHub上找到(链接在视频说明中)。
⬇️ 导入依赖项
打开main.rs file,
删除所有代码,并添加以下导入:
use postgres::{ Client, NoTls };
use postgres::Error as PostgresError;
use std::net::{ TcpListener, TcpStream };
use std::io::{ Read, Write };
use std::env;
#[macro_use]
extern crate serde_derive;
Client
用于连接数据库。NoTls
用于不使用 TLS 连接数据库。PostgresError
是 Postgres 驱动程序返回的错误类型。TcpListener
并TcpStream
创建一个 TCP 服务器。Read
并Write
用于从 TCP 流中读取和写入。env
用于读取环境变量。
该#[macro_use]
属性用于导入serde_derive
宏。
我们将用它来推导我们的模型Serialize
和Deserialize
特征。
🥻 创建模型
在导入的正下方,添加以下代码:
//Model: User struct with id, name, email
#[derive(Serialize, Deserialize)]
struct User {
id: Option<i32>,
name: String,
email: String,
}
我们将使用该模型来表示我们应用程序中的用户。
-
id
是一个整数,并且是可选的。原因是我们在创建或更新新用户时没有提供 ID。数据库会为我们生成 ID。但我们仍然希望在获取用户时返回带有 ID 的用户。 -
name
是一个字符串,并且是必需的。我们将用它来存储用户的名称。
email
是一个字符串,并且是必填项。我们将用它来存储用户的电子邮件(无需检查其是否有效)。
🪨常量
在模型下方,添加以下常量:
//DATABASE URL
const DB_URL: &str = env!("DATABASE_URL");
//cosntants
const OK_RESPONSE: &str = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n";
const NOT_FOUND: &str = "HTTP/1.1 404 NOT FOUND\r\n\r\n";
const INTERNAL_ERROR: &str = "HTTP/1.1 500 INTERNAL ERROR\r\n\r\n";
DB_URL
是数据库的 URL。我们将从环境变量中读取它。在本例中,我们将标头添加Content-Type: application/json
到响应中。OK_RESPONSE,
NOT_FOUND
和INTERNAL_ERROR
是我们将发送回客户端的响应。我们将使用它们来返回状态码和内容类型。
🏠 主要功能
在常量下方添加以下代码:
//main function
fn main() {
//Set Database
if let Err(_) = set_database() {
println!("Error setting database");
return;
}
//start server and print port
let listener = TcpListener::bind(format!("0.0.0.0:8080")).unwrap();
println!("Server listening on port 8080");
for stream in listener.incoming() {
match stream {
Ok(stream) => {
handle_client(stream);
}
Err(e) => {
println!("Unable to connect: {}", e);
}
}
}
}
set_database
是我们稍后要创建的函数。它将用于连接数据库。TcpListener::bind
用于在端口 8080 上创建 TCP 服务器。listener.incoming()
用于获取传入连接。
⛑️ 实用函数
现在,在主函数之外,添加以下三个实用函数:
//db setup
fn set_database() -> Result<(), PostgresError> {
let mut client = Client::connect(DB_URL, NoTls)?;
client.batch_execute(
"
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
email VARCHAR NOT NULL
)
"
)?;
Ok(())
}
//Get id from request URL
fn get_id(request: &str) -> &str {
request.split("/").nth(2).unwrap_or_default().split_whitespace().next().unwrap_or_default()
}
//deserialize user from request body without id
fn get_user_request_body(request: &str) -> Result<User, serde_json::Error> {
serde_json::from_str(request.split("\r\n\r\n").last().unwrap_or_default())
}
set_database
连接到数据库,users
如果表不存在则创建表。get_id
用于从请求URL中获取id。get_user_request_body
用于从Create
和Update
端点的请求主体(不带id)反序列化用户。
🚦 处理客户端
在主函数和实用函数之间,添加以下代码(不用担心,文章末尾会有最终代码):
//handle requests
fn handle_client(mut stream: TcpStream) {
let mut buffer = [0; 1024];
let mut request = String::new();
match stream.read(&mut buffer) {
Ok(size) => {
request.push_str(String::from_utf8_lossy(&buffer[..size]).as_ref());
let (status_line, content) = match &*request {
r if r.starts_with("POST /users") => handle_post_request(r),
r if r.starts_with("GET /users/") => handle_get_request(r),
r if r.starts_with("GET /users") => handle_get_all_request(r),
r if r.starts_with("PUT /users/") => handle_put_request(r),
r if r.starts_with("DELETE /users/") => handle_delete_request(r),
_ => (NOT_FOUND.to_string(), "404 not found".to_string()),
};
stream.write_all(format!("{}{}", status_line, content).as_bytes()).unwrap();
}
Err(e) => eprintln!("Unable to read stream: {}", e),
}
}
我们为传入的请求创建一个缓冲区,然后创建一个字符串。
使用match
Rust 中的语句,我们可以检查请求并调用正确的函数来处理它。
如果没有匹配,我们会发回404
错误。
最后,我们设置流将响应写回客户端并处理任何错误。
🎛️ 控制器
现在,让我们创建处理请求的函数。
它们有五个函数,每个端点一个:
handle_post_request
对于Create
端点handle_get_request
对于Read
端点handle_get_all_request
对于Read All
端点handle_put_request
对于Update
端点handle_delete_request
对于Delete
端点
在函数下方添加代码handle_client
:
//handle post request
fn handle_post_request(request: &str) -> (String, String) {
match (get_user_request_body(&request), Client::connect(DB_URL, NoTls)) {
(Ok(user), Ok(mut client)) => {
client
.execute(
"INSERT INTO users (name, email) VALUES ($1, $2)",
&[&user.name, &user.email]
)
.unwrap();
(OK_RESPONSE.to_string(), "User created".to_string())
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle get request
fn handle_get_request(request: &str) -> (String, String) {
match (get_id(&request).parse::<i32>(), Client::connect(DB_URL, NoTls)) {
(Ok(id), Ok(mut client)) =>
match client.query_one("SELECT * FROM users WHERE id = $1", &[&id]) {
Ok(row) => {
let user = User {
id: row.get(0),
name: row.get(1),
email: row.get(2),
};
(OK_RESPONSE.to_string(), serde_json::to_string(&user).unwrap())
}
_ => (NOT_FOUND.to_string(), "User not found".to_string()),
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle get all request
fn handle_get_all_request(_request: &str) -> (String, String) {
match Client::connect(DB_URL, NoTls) {
Ok(mut client) => {
let mut users = Vec::new();
for row in client.query("SELECT id, name, email FROM users", &[]).unwrap() {
users.push(User {
id: row.get(0),
name: row.get(1),
email: row.get(2),
});
}
(OK_RESPONSE.to_string(), serde_json::to_string(&users).unwrap())
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle put request
fn handle_put_request(request: &str) -> (String, String) {
match
(
get_id(&request).parse::<i32>(),
get_user_request_body(&request),
Client::connect(DB_URL, NoTls),
)
{
(Ok(id), Ok(user), Ok(mut client)) => {
client
.execute(
"UPDATE users SET name = $1, email = $2 WHERE id = $3",
&[&user.name, &user.email, &id]
)
.unwrap();
(OK_RESPONSE.to_string(), "User updated".to_string())
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle delete request
fn handle_delete_request(request: &str) -> (String, String) {
match (get_id(&request).parse::<i32>(), Client::connect(DB_URL, NoTls)) {
(Ok(id), Ok(mut client)) => {
let rows_affected = client.execute("DELETE FROM users WHERE id = $1", &[&id]).unwrap();
//if rows affected is 0, user not found
if rows_affected == 0 {
return (NOT_FOUND.to_string(), "User not found".to_string());
}
(OK_RESPONSE.to_string(), "User deleted".to_string())
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
-
有些使用
get_id
函数从请求URL中获取id。 -
该
get_user_request_body
函数用于从请求体中获取JSON格式的用户,并将其反序列化为User
结构体。 -
如果请求无效或数据库连接失败,则会进行一些错误处理。
📝 回顾
完整文件如下main.rs
:
use postgres::{ Client, NoTls };
use postgres::Error as PostgresError;
use std::net::{ TcpListener, TcpStream };
use std::io::{ Read, Write };
use std::env;
#[macro_use]
extern crate serde_derive;
//Model: User struct with id, name, email
#[derive(Serialize, Deserialize)]
struct User {
id: Option<i32>,
name: String,
email: String,
}
//DATABASE URL
const DB_URL: &str = env!("DATABASE_URL");
//constants
const OK_RESPONSE: &str = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n";
const NOT_FOUND: &str = "HTTP/1.1 404 NOT FOUND\r\n\r\n";
const INTERNAL_ERROR: &str = "HTTP/1.1 500 INTERNAL ERROR\r\n\r\n";
//main function
fn main() {
//Set Database
if let Err(_) = set_database() {
println!("Error setting database");
return;
}
//start server and print port
let listener = TcpListener::bind(format!("0.0.0.0:8080")).unwrap();
println!("Server listening on port 8080");
for stream in listener.incoming() {
match stream {
Ok(stream) => {
handle_client(stream);
}
Err(e) => {
println!("Unable to connect: {}", e);
}
}
}
}
//handle requests
fn handle_client(mut stream: TcpStream) {
let mut buffer = [0; 1024];
let mut request = String::new();
match stream.read(&mut buffer) {
Ok(size) => {
request.push_str(String::from_utf8_lossy(&buffer[..size]).as_ref());
let (status_line, content) = match &*request {
r if r.starts_with("POST /users") => handle_post_request(r),
r if r.starts_with("GET /users/") => handle_get_request(r),
r if r.starts_with("GET /users") => handle_get_all_request(r),
r if r.starts_with("PUT /users/") => handle_put_request(r),
r if r.starts_with("DELETE /users/") => handle_delete_request(r),
_ => (NOT_FOUND.to_string(), "404 not found".to_string()),
};
stream.write_all(format!("{}{}", status_line, content).as_bytes()).unwrap();
}
Err(e) => eprintln!("Unable to read stream: {}", e),
}
}
//handle post request
fn handle_post_request(request: &str) -> (String, String) {
match (get_user_request_body(&request), Client::connect(DB_URL, NoTls)) {
(Ok(user), Ok(mut client)) => {
client
.execute(
"INSERT INTO users (name, email) VALUES ($1, $2)",
&[&user.name, &user.email]
)
.unwrap();
(OK_RESPONSE.to_string(), "User created".to_string())
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle get request
fn handle_get_request(request: &str) -> (String, String) {
match (get_id(&request).parse::<i32>(), Client::connect(DB_URL, NoTls)) {
(Ok(id), Ok(mut client)) =>
match client.query_one("SELECT * FROM users WHERE id = $1", &[&id]) {
Ok(row) => {
let user = User {
id: row.get(0),
name: row.get(1),
email: row.get(2),
};
(OK_RESPONSE.to_string(), serde_json::to_string(&user).unwrap())
}
_ => (NOT_FOUND.to_string(), "User not found".to_string()),
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle get all request
fn handle_get_all_request(_request: &str) -> (String, String) {
match Client::connect(DB_URL, NoTls) {
Ok(mut client) => {
let mut users = Vec::new();
for row in client.query("SELECT id, name, email FROM users", &[]).unwrap() {
users.push(User {
id: row.get(0),
name: row.get(1),
email: row.get(2),
});
}
(OK_RESPONSE.to_string(), serde_json::to_string(&users).unwrap())
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle put request
fn handle_put_request(request: &str) -> (String, String) {
match
(
get_id(&request).parse::<i32>(),
get_user_request_body(&request),
Client::connect(DB_URL, NoTls),
)
{
(Ok(id), Ok(user), Ok(mut client)) => {
client
.execute(
"UPDATE users SET name = $1, email = $2 WHERE id = $3",
&[&user.name, &user.email, &id]
)
.unwrap();
(OK_RESPONSE.to_string(), "User updated".to_string())
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//handle delete request
fn handle_delete_request(request: &str) -> (String, String) {
match (get_id(&request).parse::<i32>(), Client::connect(DB_URL, NoTls)) {
(Ok(id), Ok(mut client)) => {
let rows_affected = client.execute("DELETE FROM users WHERE id = $1", &[&id]).unwrap();
//if rows affected is 0, user not found
if rows_affected == 0 {
return (NOT_FOUND.to_string(), "User not found".to_string());
}
(OK_RESPONSE.to_string(), "User deleted".to_string())
}
_ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
}
}
//db setup
fn set_database() -> Result<(), PostgresError> {
let mut client = Client::connect(DB_URL, NoTls)?;
client.batch_execute(
"
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
email VARCHAR NOT NULL
)
"
)?;
Ok(())
}
//Get id from request URL
fn get_id(request: &str) -> &str {
request.split("/").nth(2).unwrap_or_default().split_whitespace().next().unwrap_or_default()
}
//deserialize user from request body without id
fn get_user_request_body(request: &str) -> Result<User, serde_json::Error> {
serde_json::from_str(request.split("\r\n\r\n").last().unwrap_or_default())
}
我们已经完成了应用程序代码。现在轮到 Docker 了。
🐳 Docker
我们将直接在镜像中构建 Rust 应用程序。我们将使用官方 Rust 镜像作为基础镜像。我们还将使用官方 Postgres 镜像作为数据库的基础镜像。
我们将创建三个文件:
- .dockerignore:忽略我们不想在镜像文件系统中复制的文件和文件夹
- Dockerfile:构建 Rust 镜像
- docker-compose.yml:运行 Rust 和 Postgres 服务(容器)
您可以使用终端或代码编辑器创建它们。
touch .dockerignore Dockerfile docker-compose.yml
🚫 .dockerignore
打开.dockerignore文件并添加以下内容:
**/target
这是为了避免复制图像文件系统中的目标文件夹。
🐋 Dockerfile
我们将使用多阶段构建。我们将:
- 构建阶段:构建 Rust 应用程序
- 生产阶段:运行 Rust 应用程序
打开Dockerfile并添加以下内容(注释中有解释):
# Build stage
FROM rust:1.69-buster as builder
WORKDIR /app
# Accept the build argument
ARG DATABASE_URL
# Make sure to use the ARG in ENV
ENV DATABASE_URL=$DATABASE_URL
# Copy the source code
COPY . .
# Build the application
RUN cargo build --release
# Production stage
FROM debian:buster-slim
WORKDIR /usr/local/bin
COPY --from=builder /app/target/release/rust-crud-api .
CMD ["./rust-crud-api"]
请注意,我们使用的rust-crud-api
是可执行文件的名称。这是项目文件夹的名称。如果您使用其他名称,请更改。
🐙 docker-compose.yml
docker-compose.yml
使用以下内容填充文件:
version: '3.9'
services:
rustapp:
container_name: rustapp
image: francescoxx/rustapp:1.0.0
build:
context: .
dockerfile: Dockerfile
args:
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres
ports:
- '8080:8080'
depends_on:
- db
db:
container_name: db
image: 'postgres:12'
ports:
- '5432:5432'
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=postgres
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata: {}
-
我们有两个服务,
rustapp
和db
。该rustapp
服务使用我们之前创建的 Dockerfile 构建。该db
服务使用官方 Postgres 镜像。我们使用depends_on
属性来确保db
服务在 服务之前启动rustapp
。 -
请注意,
DATABASE_URL
build 参数设置为postgres://postgres:postgres@db:5432/postgres
.db
是 Postgres 容器的服务名称(和 container_name),以便它将被解析为容器 IP 地址。 -
我们使用该
arg
属性将DATABASE_URL
构建参数传递给 Dockerfile。 -
我们还使用命名卷
pg_data
来保存数据库数据。
现在是时候构建图像并运行容器了。
🏗️ 构建镜像并运行容器
我们只需要再执行三个步骤:
- 运行 postgres 容器
- 构建 Rust 应用程序镜像
- 运行 Rust 应用程序容器
🐘 运行 Postgres 容器
首先,运行 postgres 容器:
docker-compose up -d db
这将从 DockerHub 拉取(下载)图像并在我们的机器上运行它。
要查看日志,您可以输入
docker-compose logs db
如果有类似这样的内容,则表示数据库在容器中启动并运行(日志的最后一行应该显示:“数据库系统已准备好接受连接”)
🏗️ 构建 Rust 应用程序镜像
现在是时候构建 Rust 应用程序镜像了。我们将使用docker-compose build
命令。
它将使用我们之前创建的 Dockerfile 来构建镜像。
(注意:我们可能会输入docker compose up
,但这样做,我们将无法理解发生了什么。简而言之,当我们输入时docker compose up
,Docker 会在需要时构建图像,然后运行容器)。
docker compose build
这需要时间,因为我们在图像中构建 Rust 应用程序。
大约 150 秒后(!),我们应该已经构建好了图像。
👟 运行 Rust 容器
现在我们可以运行 Rust 容器:
docker compose up rustapp
您可以通过打开另一个终端并输入以下内容来检查两个容器:
docker ps -a
最后,您可以通过输入以下命令检查 postgres 数据库:
docker exec -it db psql -U postgres
\dt
select * from users;
以下是输出的屏幕截图:
现在是时候测试我们的应用程序了。
🧪 测试应用程序
为了测试该应用程序,我们将使用 Postman。您可以从此处下载。
📝 测试数据库连接
由于我们没有专用端点来测试数据库连接,因此我们将创建一个GET request to http://localhost:8080/users
输出应该是[]
。这是正确的,因为数据库是空的。
📝 创建新用户
要创建新用户,请使用POST request to http://localhost:8080/users
以下内容:
⚠️ 在请求中添加标头“Content-Type: application/json”
{
"name": "aaa",
"email": "aaa@mail"
}
在同一端点上创建另外两个具有以下主体的用户,从而POST request to http://localhost:8080/users
{
"name": "bbb",
"email": "bbb@mail"
}
{
"name": "ccc",
"email": "ccc@mail"
}
📝 获取所有用户
要获取所有用户,请制作GET request to http://localhost:8080/users
📝 获取单个用户(带错误处理)
要获取单个用户,我们可以在 URL 中指定 id。
例如,为了获取 ID 为 1 的用户,我们可以GET request to http://localhost:8080/users/1
请注意,如果我们尝试获取具有不存在的 ID 的用户,则会收到错误。
做一个GET request to http://localhost:8080/users/10
如果我们尝试使用字符串而不是整数来获取用户 py,我们也会收到错误。
做一个GET request to http://localhost:8080/users/aaa
📝 更新用户
我们必须在 URL 中传递一个 id,并在 body 中传递新数据来更新现有用户。
例如,制作一个PUT request to http://localhost:8080/users/2
具有以下主体的文件:
{
"name": "NEW",
"email": "NEW@mail"
}
📝 删除用户
最后,要删除用户,我们需要在 URL 中传递 id。
例如,制作一个DELETE request to http://localhost:8080/users/3
🐢 使用 TablePlus 进行测试
您还可以使用 TablePlus 测试应用程序。
使用以下凭据创建一个新的 Postgres 连接:
- 主机:localhost
- 端口:5432
- 用户:postgres
- 密码:postgres
- 数据库:postgres
然后点击connect
右下角的按钮。
这将打开一个包含数据库的新窗口。
您可以检查users
表格并查看数据是否在那里。
完毕。
🏁 结论
我们成功了!
我们使用 Rust、Serde、Postgres 和 Docker 创建了一个 REST API。
如果您更喜欢视频版本:
所有代码均可在 GitHub 存储库中找到(链接在视频描述中):https://youtu.be/vhNoiBOuW94
就这样。
如果您有任何疑问,请在下面发表评论。
文章来源:https://dev.to/francescoxx/rust-crud-rest-api-3n45