用 Rust Actix-web 写一个 Todo 应用(一)── Hello world 和 REST 接口
Actix
actix 是 Rust 生态中的 Actor 系统。actix-web 是在 actix actor 框架和 Tokio 异步 IO 系统之上构建的高级 Web 框架。
本篇博客实践使用 actix-web 实现一个简单的 todo 应用。基本要求:了解 rust 基本语法,了解一定的 sql 和 docker 知识。
创建一个 Hello world 程序
首先,新建一个 todo-list
项目,并在其中增加 actix-web
依赖,我们使用最新的 actix 3.0。
cargo new todo-list
cd todo-list
Cargo.toml
:
[package]
name = "todo-list"
version = "0.1.0"
authors = ["qiwihui <qwh005007@gmail.com>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = "3"
在 main.rs
中,使用类似于 python flask 的语法,增加一个最简单的 service。
use actix_web::{get, App, HttpServer, Responder}; use std::io::Result; #[get("/")] async fn hello() -> impl Responder { // String 实现了 Responder trait format!("Hello world!") } #[actix_web::main] async fn main() -> Result<()> { println!("Starting server at http://127.0.0.1:8000"); HttpServer::new(|| App::new().service(hello)) .bind("127.0.0.1:8000")? .run() .await }
运行并测试:
cargo run
在另一个终端中
$ curl 127.0.0.1:8000
Hello world!
数据库设计
项目中将使用 postgres 作为数据库存储,为了方便操作和管理,我们使用 docker-compose 进行管理。
docker-compose.yml
version: "3"
services:
postgres:
image: postgres:11-alpine
container_name: postgres
restart: always
environment:
POSTGRES_PASSWORD: actix
POSTGRES_USER: actix
POSTGRES_DB: actix
ports:
- 5432:5432
创建数据库:
docker-compose up -d
然后,我们设计整体数据库表结构,并创建一些基础数据作为测试。表结构如下:
TodoList TodoItem
+---------+
| id |
+-------+ +---------+
| id + <-- FK --+ list_id |
+-------+ +---------+
| title | | title |
+-------+ +---------+
| checked |
+---------+
在 database.sql
中手动创建表结构并插入数据:
drop table if exists todo_list;
drop table if exists todo_item;
create table todo_list (
id serial primary key,
title varchar(150) not null
);
create table todo_item (
id serial primary key,
title varchar(150) not null,
checked boolean not null default false,
list_id integer not null,
foreign key (list_id) references todo_list(id)
);
insert into
todo_list (title)
values
('List 1'),
('List 2');
insert into
todo_item (title, list_id)
values
('item 1', 1),
('item 2', 1);
创建数据表并查看结果
$ psql -h 127.0.0.1 -p 5432 -U actix actix < database.sql
Password for user actix:
NOTICE: table "todo_list" does not exist, skipping
DROP TABLE
NOTICE: table "todo_item" does not exist, skipping
DROP TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 2
$ psql -h 127.0.0.1 -p 5432 -U actix actix
Password for user actix:
psql (12.4, server 11.9)
Type "help" for help.
actix=# \d
List of relations
Schema | Name | Type | Owner
--------+------------------+----------+-------
public | todo_item | table | actix
public | todo_item_id_seq | sequence | actix
public | todo_list | table | actix
public | todo_list_id_seq | sequence | actix
(4 rows)
actix=# select * from todo_list;
id | title
----+--------
1 | List 1
2 | List 2
(2 rows)
获取 todo 列表
首先,添加我们需要的库,其中 serde
用于序列化,tokio-postgres
是一直支持异步的 PostgreSQL 客户端,deadpool-postgres
用于连接池的管理。
[dependencies]
actix-web = "3"
serde="1.0.117"
deadpool-postgres = "0.5.0"
tokio-postgres = "0.5.1"
增加 models.rs
用于管理数据模型,并支持序列化和反序列化。
#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] pub struct TodoList { pub id: i32, pub title: String, } #[derive(Serialize, Deserialize)] pub struct TodoItem { pub id: i32, pub title: String, pub checked: bool, pub list_id: i32, } }
增加 db.rs
用于管理数据操作,例如 get_todos
从数据库中获取数据并序列化为 TodoList
的数组:
#![allow(unused)] fn main() { use crate::models::{TodoItem, TodoList}; use deadpool_postgres::Client; use std::io::Error; use tokio_postgres::Row; // 将每条记录转为 TodoList fn row_to_todo(row: &Row) -> TodoList { let id: i32 = row.get(0); let title: String = row.get(1); TodoList { id, title } } pub async fn get_todos(client: &Client) -> Result<Vec<TodoList>, Error> { let statement = client .prepare("select * from todo_list order by id desc") .await .unwrap(); let todos = client .query(&statement, &[]) .await .expect("Error getting todo lists") .iter() .map(|row| row_to_todo(row)) .collect::<Vec<TodoList>>(); Ok(todos) } }
增加 handlers.rs
用于处理服务:
#![allow(unused)] fn main() { use crate::db::get_todos; use actix_web::{web, HttpResponse, Responder}; use deadpool_postgres::{Client, Pool}; pub async fn todos(db_pool: web::Data<Pool>) -> impl Responder { let client: Client = db_pool .get() .await .expect("Error connecting to the database"); let result = get_todos(&client).await; match result { Ok(todos) => HttpResponse::Ok().json(todos), Err(_) => HttpResponse::InternalServerError().into(), } } }
最后,在 main.rs
中创建连接池并添加路由:
mod db; mod handlers; mod models; use actix_web::{get, web, App, HttpServer, Responder}; use deadpool_postgres; use handlers::todos; use std::io; use tokio_postgres::{self, NoTls}; #[get("/")] async fn hello() -> impl Responder { format!("Hello world!") } #[actix_web::main] async fn main() -> io::Result<()> { println!("Starting server at http://127.0.0.1:8000"); // 创建连接池 let mut cfg = tokio_postgres::Config::new(); cfg.host("localhost"); cfg.port(5432); cfg.user("actix"); cfg.password("actix"); cfg.dbname("actix"); let mgr = deadpool_postgres::Manager::new(cfg, NoTls); let pool = deadpool_postgres::Pool::new(mgr, 100); HttpServer::new(move || { App::new() .data(pool.clone()) .service(hello) .route("/todos{_:/?}", web::get().to(todos)) }) .bind("127.0.0.1:8000")? .run() .await }
运行并测试:
$ cargo run
# 另一个总端,jq 用于格式化返回的 json
$ curl 127.0.0.1:8000/todos | jq
[
{
"id": 2,
"title": "List 2"
},
{
"id": 1,
"title": "List 1"
}
]
两个改进
- 数据库的连接信息硬编码在代码中,在实际使用中会使用环境变量进行设置
添加 .env
配置数据库连接信息和服务端口:
SERVER.HOST=127.0.0.1
SERVER.PORT=8000
PG.USER=actix
PG.PASSWORD=actix
PG.HOST=127.0.0.1
PG.PORT=5432
PG.DBNAME=actix
PG.POOL.MAX_SIZE=30
同时,通过环境变量获取对应配置。首先增加 dotenv
和 config
依赖:
[dependencies]
# ... 省略
dotenv = "0.15.0"
config = "0.10.1"
然后增加 config.rs
,增加从环境变量中获取配置并生成连接池方法 from_env
:
#![allow(unused)] fn main() { use config::{self, ConfigError}; use serde::Deserialize; #[derive(Deserialize, Debug)] pub struct ServerConfig { pub host: String, pub port: i32, } #[derive(Deserialize, Debug)] pub struct Config { pub server: ServerConfig, pub pg: deadpool_postgres::Config, } impl Config { pub fn from_env() -> Result<Self, ConfigError> { let mut cfg = config::Config::new(); cfg.merge(config::Environment::new())?; cfg.try_into() } } }
在 main.rs
中使用环境变量创建连接池:
mod config; use dotenv::dotenv; // ...省略 #[actix_web::main] async fn main() -> io::Result<()> { // 环境变量 dotenv().ok(); // 连接池 let cfg = crate::config::Config::from_env().unwrap(); let pool = cfg.pg.create_pool(NoTls).unwrap(); println!( "Starting server at http://{}:{}", cfg.server.host, cfg.server.port ); HttpServer::new(move || { App::new() .data(pool.clone()) .service(hello) .route("/todos{_:/?}", web::get().to(todos)) }) .bind(format!("{}:{}", cfg.server.host, cfg.server.port))? .run() .await }
db.rs
中row_to_todo
函数太麻烦,使用tokio_pg_mapper
做处理,简化操作:
[dependencies]
# ... 省略
tokio-pg-mapper = "0.1"
tokio-pg-mapper-derive = "0.1"
在 models.rs
中添加 PostgresMapper
,
#![allow(unused)] fn main() { use serde::{Deserialize, Serialize}; use tokio_pg_mapper_derive::PostgresMapper; #[derive(Serialize, Deserialize, PostgresMapper)] #[pg_mapper(table = "todo_list")] pub struct TodoList { pub id: i32, pub title: String, } #[derive(Serialize, Deserialize, PostgresMapper)] #[pg_mapper(table = "todo_item")] pub struct TodoItem { pub id: i32, pub title: String, pub checked: bool, pub list_id: i32, } }
使用 from_row_ref
方法将记录进行转换:
#![allow(unused)] fn main() { use tokio_pg_mapper::FromTokioPostgresRow; pub async fn get_todos(client: &Client) -> Result<Vec<TodoList>, Error> { // ... 省略 let todos = client .query(&statement, &[]) .await .expect("Error getting todo lists") .iter() // 修改 .map(|row| TodoList::from_row_ref(row).unwrap()) .collect::<Vec<TodoList>>(); Ok(todos) } }
小结
- 创建 hello world 程序;
- 创建数据库连接和获取数据;
- 使用环境变量;
参考文档和项目
GitHub repo: qiwihui/blog
Follow me: @qiwihui
Site: QIWIHUI