开始试用Rust的Web开发组件actix-web

  • 本篇文章主要用于开发记录,不对知识点做详细讲解。关于知识点的讲解可参考零基础学Rust视频
  • **代码已提交github **
  1. 使用cargo new新建一个项目rust_login用于实现用户登录功能。 
  2. 在Cargo.toml文件中配置需要的依赖
[package]
name = "rust_login"
version = "0.1.0"
authors = ["Tianlang <tianlangstuido@aliyun.com>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
actix-web="2" #使用的actix-web 提供web服务器、request解析、response生成等功能
actix-rt="1" #actix-rt actix的运行时,用于运行异步函数等,可以理解为Java concurrent下的Executor
#serde用于序列化和反序列化对象的,比如把对象转换成一个Json字符串,就是序列化; 
#把Json字符串转换为一个对象,就是反序列化
serde="1" 

``` 

3.  在src/main.rs文件中敲入以下代码 
```rust
use actix_web::{post, web, App, HttpServer, Responder};
use serde::Deserialize;
//用于表示请求传来的Json对象
#[derive(Deserialize)]
struct LoginInfo {
   username: String,
   password: String,
}
#[post("/login")] //声明请求方式和请求路径,接受post方式请求/login路径
async fn index(login_info: web::Json<LoginInfo>) -> impl Responder {
    format!("Hello {}! password:{}",login_info.username , login_info.password)
}

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
 //启动http服务器
    HttpServer::new(|| App::new().service(index))
        .bind("127.0.0.1:8088")?
        .run()
        .await
}

  1. 使用cargo run 运行程序
  2. 执行curl请求我们编写的login路径
curl -v -H "Content-Type:application/json" -X POST --data '{"username":"tianalng", password:"tianlang"}' http://127.0.0.1:8088/login

没有访问成功:

* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 8088 (#0)
> POST /login HTTP/1.1
> Host: 127.0.0.1:8088
> User-Agent: curl/7.58.0
> Accept: */*
> Content-Type:application/json
> Content-Length: 44
> 
* upload completely sent off: 44 out of 44 bytes
< HTTP/1.1 400 Bad Request
< content-length: 0
< date: Sat, 16 May 2020 23:20:07 GMT
< 
* Connection #0 to host 127.0.0.1 left intact

从返回的错误信息

400 Bad Request

可以看出这是因为客户端请求不满足服务端也就是我们写的login服务要求造成的 一般看到4开始的http错误码,我们可以认为是客户端没写好。如果是5开头的可以认为是服务端没写好。  也可以搜索下:

在 ajax 请求后台数据时比较常见。产生 HTTP 400 错误的原因有:

1、前端提交数据的字段名称或者是字段类型和后台的实体类不一致,导致无法封装;
2、前端提交的到后台的数据应该是 json 字符串类型,而前端没有将对象转化为字符串类型

接下来我们检查下curl命令,可以看到password缺少双引号,把双引号加上,再执行下:

curl -v -H "Content-Type:application/json" -X POST --data '{"username":"tianalng", "password":"tianlang"}' http://127.0.0.1:8088/login
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 8088 (#0)
> POST /login HTTP/1.1
> Host: 127.0.0.1:8088
> User-Agent: curl/7.58.0
> Accept: */*
> Content-Type:application/json
> Content-Length: 46
> 
* upload completely sent off: 46 out of 46 bytes
< HTTP/1.1 200 OK
< content-length: 33
< content-type: text/plain; charset=utf-8
< date: Sat, 16 May 2020 23:22:48 GMT
< 
* Connection #0 to host 127.0.0.1 left intact
Hello tianalng! password:tianlang

这次就成功了  现在我们可以获取到用户提交的用户名密码了,简单起见,接下来我们判断用户名是不是等于密码,如果相等就返回OK告诉客户端登录成功了,如果不相等就返回Error告诉客户端登录失败了。

在index函数中使用if语句判断用户名是否跟密码一致,如果一致就返回成功如果不一致就返回失败,当然这里也可以使用match,代码如下:

#[post("/login")]
async fn index(login_info: web::Json<LoginInfo>) -> impl Responder {
    if login_info.username == login_info.password {
        HttpResponse::Ok().json("success")
    } else {
        HttpResponse::Forbidden().json("password error")
    }
    
}

其中HttpResponse::Ok设置结果成功也就是对应http的状态码200 HttpResponse::Forbidden设置结果为拒绝请求也就是对应http的状态码403

你可以继续使用curl分别使用与用户名一致的密码和不一致的密码测试: 

 curl -v -H "Content-Type:application/json" -X POST --data '{"username":"tianlang", "password":"tianlang"}' http://127.0.0.1:8088/login
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 8088 (#0)
> POST /login HTTP/1.1
> Host: 127.0.0.1:8088
> User-Agent: curl/7.58.0
> Accept: */*
> Content-Type:application/json
> Content-Length: 46
> 
* upload completely sent off: 46 out of 46 bytes
**< HTTP/1.1 200 OK**
< content-length: 9
< content-type: application/json
< date: Sat, 23 May 2020 11:36:30 GMT
< 
* Connection #0 to host 127.0.0.1 left intact
**"success"**
curl -v -H "Content-Type:application/json" -X POST --data '{"username":"tianlang", "password":"wrong"}' http://127.0.0.1:8088/login
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 8088 (#0)
> POST /login HTTP/1.1
**> Host: 127.0.0.1:8088**
> User-Agent: curl/7.58.0
> Accept: */*
> Content-Type:application/json
> Content-Length: 43
> 
* upload completely sent off: 43 out of 43 bytes
< HTTP/1.1 403 Forbidden
< content-length: 16
< content-type: application/json
< date: Sat, 23 May 2020 11:37:27 GMT
< 
* Connection #0 to host 127.0.0.1 left intact
**"password error"**

也可以使用postman构造一个post请求: rust login success 这样就可以根据客户端提供的数据返回不同的结果了,代码已提交github 现在还存在个问题: 虽然是调用的json设置的返回结果,但返回结果仍然是一个普通的字符串,在前端页面是不能调用JSON.parse()转换为json对象的。接下来我们要定义个struct统一表示返回的数据样式,这样客户端可以统一转换成json方便解析处理。 

返回Json格式数据

首先我们定义一个struct用来表示http接口返回的数据,按照传统命名为AjaxResult.

#[derive(Deserialize)]
#[derive(Serialize)]
struct AjaxResult<T> {
    msg: String,
    data: Option<Vec<T>>,
}

需要把它序列化成json,所以需要给它添加

#[derive(Serialize)]  注解  字段msg用来存储接口执行的结果信息,接口执行成功统一设置为 success,接口执行失败就设置为失败信息。  字段data用来存储返回的数据,数据不是必须的,比如在接口执行失败的时候就没有数据返回,所以data字段是Option类型。 为了方便创建AjaxResut对象我们再添加些关联函数:

const MSG_SUCCESS: &str = "success";
impl<T> AjaxResult<T> {

    pub fn success(data_opt: Option<Vec<T>>) -> Self{
         Self {
             msg: MSG_SUCCESS.to_string(),
             data: data_opt
         }
    }

    pub fn success_without_data() -> Self {
        Self::success(Option::None)
    }
    pub fn success_with_single(single: T) -> Self{
        Self {
            msg:  MSG_SUCCESS.to_string(),
            data: Option::Some(vec![single])
        }
    }

    pub fn fail(msg: String) -> Self {
        Self {
            msg,
            data: None
        }
    }

}

接下来修改login函数,不再返回一个字符串而是返回AjaxRsult对象:

#[post("/login")]
async fn index(login_info: web::Json<LoginInfo>) -> impl Responder {
    if login_info.username == login_info.password {
        HttpResponse::Ok().json(AjaxResult::<bool>::success_without_data())
    } else {
        HttpResponse::Forbidden().json(AjaxResult::<bool>::fail("password must match username".to_string()))
    }
}

AjaxResult::<bool> 这里的bool不是设置返回值数据类型因为我们也没有返回数据而是为了告诉Rust编译器我们使用的泛型T的类型,不然它推导不出来就编译出错了。这里的bool可以换成i32、String等 

在执行下接口调用: retun json object

这时返回的数据就是标准的json对象了,方便前端解析处理。  以前我们设计AjaxResult对象时,也会包含一个数字类型的code字段用于区分不同的执行结果错误类型。我们这里直接复用http的状态码,就不需要定义这个字段了。 这也是设计Restful API的指导思想:

不是把所有的参数都尽量放到path里就是Resulful了,Restful是尽量复用已有的http规范。 纯属个人言论,如有误导概不负责
代码已提交github 现在还有个问题:  如果用户已经登录过了就不需要再判断用户名密码了,浪费资源,直接返回就可以了,怎么实现呢? 也就是如果用户已经登录过了,我们怎么知道用户已经登录过了呢?

记录用户登录状态

这个我们可以借助Session实现,Session一般代表从用户打开浏览器访问网站到关闭浏览器无论中间浏览过多少次网页一般都属于一个Session。 注意这里说的一般情况,有的浏览器可能行为不一样 可以在用户第一次登录成功后把用户的登录信息放入到Session中,判断用户名密码之前先在Session中找有没有用户信息如果有就代表用户已经登录过了,如果没有再接着判断用户名密码是否一致。要使用Session需要在Cargo.toml文件中配置actix-session依赖:


[dependencies]
actix-web="2"
actix-rt="1"
actix-session="0.3"

修改login函数中的代码如下: 

const SESSION_USER_KEY: &str = "user_info";
#[post("/login")]
async fn index(session: Session, login_info: web::Json<LoginInfo>) -> impl Responder {

    match session.get::<String>(SESSION_USER_KEY) {
        Ok(Some(user_info)) if user_info == login_info.username => {
            println!("already logged in");
            HttpResponse::Ok().json(AjaxResult::<bool>::success_without_data())
        }
        _ => {
            println!("login now");
            if login_info.username == login_info.password {
                session.set::<String>(SESSION_USER_KEY, login_info.username.clone());
                HttpResponse::Ok().json(AjaxResult::<bool>::success_without_data())
            } else {
                HttpResponse::Forbidden().json(AjaxResult::<bool>::fail("password must match username".to_string()))
            }
        }
    }
}

另外需要在创建Server时配置Session中间件

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| App::new()
        .wrap(
            CookieSession::signed(&[0; 32]) // <- create cookie based session middleware
                .secure(false),
        ).service(index))
        .bind("127.0.0.1:8088")?
        .run()
        .await
}

现在我们再使用Postman访问登录接口,第一次控制台会输出:

login now   第二次就会输出: already logged in 在Postman中也可以看到多了一个cookie,细看你细看这就是我们放入Session的用户信息:  cookie 当前的actix session中间件只支持cookie存储方式,也可以自己实现基于Redis的存储方式。  现在还有个问题 : 如果一个用户看到了我们的cookie,从cookie的内容就可以看出我们这里就是用户名,那他是不是只要知道了别人的用户名就可以伪造这个cookie模仿其他用户登录?

防止伪造登录凭证

可以再使用username生成一个签名放到cookie里,用于验证cookie里的用户信息是不是伪造的,这里我们使用blake2算法生成签名信息,blake2算法跟md5类似,但更加安全。

fn sign(text: &str) -> String {
    let sign  = Blake2b::new()
        .chain(b"change me every day")
        .chain(text)
        .result();

    format!("{:X}", sign)
}

注意生成签名的时候我们还添加了段文本"change me every day",可以看成生成签名使用的密码,这段文本用户是不知道的,而且会定期变更,这样用户就不能伪造cookie信息了
在login函数中使用sign函数生成签名信息并在判断用户是否登录时验证签名信息

const SESSION_USER_KEY: &str = "user_info";
const SESSION_USER_KEY_SIGN: &str = "user_info_sign";

#[post("/login")]
async fn index(session: Session, login_info: web::Json<LoginInfo>) -> impl Responder {

    match session.get::<String>(SESSION_USER_KEY) {
        Ok(Some(user_info)) if user_info == login_info.username => {
            println!("already logged in");
            let user_key_sign = sign(&user_info);
            match session.get::<String>(SESSION_USER_KEY_SIGN) {
                Ok(Some(user_key_sign_session)) if user_key_sign == user_key_sign_session => {
                    HttpResponse::Ok().json(AjaxResult::<bool>::success_without_data())
                }
                _ => {
                    session.remove(SESSION_USER_KEY_SIGN);
                    session.remove(SESSION_USER_KEY);
                    HttpResponse::Forbidden().json(AjaxResult::<bool>::fail("Login time expired".to_string()))
                }
            }

        }
        _ => {
            println!("login now");
            if login_info.username == login_info.password {
                let user_key_sign =  sign(&login_info.username);
                session.set::<String>(SESSION_USER_KEY_SIGN, user_key_sign);
                session.set::<String>(SESSION_USER_KEY, login_info.username.clone());
                HttpResponse::Ok().json(AjaxResult::<bool>::success_without_data())
            } else {
                HttpResponse::Forbidden().json(AjaxResult::<bool>::fail("password must match username".to_string()))
            }
        }
    }
}

现在还存在个问题 : 虽然用户不知道签名信息怎么生成的就不好伪造别人的登录cookie了,但是还可以通过网络截获的方式也就是在浏览器和服务器之间传递数据时获取别人的cookie用户信息和签名信息,怎么保证cookie在网络传递时是安全的呢?

代码已提交github  

确保数据传输安全

如果注意下上面的github访问链接,你会发现它的开头是https而不是http。这是因为https比http要安全的多,具体怎么样安全需要自行搜索https、ssl/tls ,接下来在我们的程序中集成rustls提供https服务。首先配置cargo.toml文件引入需要的依赖:

[dependencies]
actix-web={version = "2.0.0", features=["rustls"]}
actix-rt="1"
actix-session="0.3"                                         
blake2 = "0.8"
rustls = "0.16"
serde="1"
actix-files = "0.2.1"

新增了俩个依赖项:rustls和actix-files 用于读取证书密钥文件,修改了actix-web依赖项,增加features=["rustls"]选项,这跟actix官方的示例配置不一样,因为官方示例使用的是1.x版本的actix-web
在main函数中读取证书密钥文件并使用https服务监听8443端口

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    let mut config = ServerConfig::new(NoClientAuth::new());
    let cert_file = &mut BufReader::new(File::open("./conf/cert.pem").unwrap());
    let key_file = &mut BufReader::new(File::open("./conf/key.pem").unwrap());
    let cert_chain = certs(cert_file).unwrap();
    let mut keys = rsa_private_keys(key_file).unwrap();
    config.set_single_cert(cert_chain, keys.remove(0)).unwrap();
 
    HttpServer::new(|| App::new()
        .wrap(
            CookieSession::signed(&[0; 32]) // <- create cookie based session middleware
                .secure(false),
        ).service(index))
        .bind_rustls("127.0.0.1:8443", config)?
        .run()
        .await
}  

使用curl访问https服务 :

curl -v -H "Content-Type:application/json" -X POST  --insecure /home/tianlang/.local/share/mkcert --data '{"username":"tianlang", "password":"tianlang"}' https://localhost:8443/login

注意 使用了--insecure选项,因为我们的证书并不是权威机构颁发的是我们自己开发使用的

看到:

{"msg":"success","data":null}

就说明配置成功了!

现在还存在个问题: 我们一般在开发环境中使用http,环境配置测试起来都方便,在正式环境中才启用https,怎么做到代码编译一次即能在开发环境中使用http用于测试又能发布到正式环境中使用https呢 ?

是不是可以通过features实现?像上面配置actix-web时指定对应的feature启用相应的功能?

不可以,因为features是在代码编译时起作用的,而我们想在代码运行时控制具体是使用http还是https.
那怎么办呢? 可以使用config

引入Config

  • 在Cargo.toml配置config依赖
[dependencies]
actix-web={version = "2.0.0", features=["rustls"]}
actix-rt="1"
actix-session="0.3"
blake2 = "0.8"
rustls = "0.16"
serde="1"
actix-files = "0.2.1"
config="0.10
  • 在main.rs文件中读取配置文件并根据配置信息启用相应功能
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    let mut app_config = config::Config::new();
    app_config.merge(config::File::with_name("conf/application")).unwrap();

    let is_prod = match app_config.get_str("tl.app.mode")  {
        Ok(value) => {
            let config_file_name = format!("conf/application_{}", value);
            app_config.merge(config::File::with_name(&config_file_name)).unwrap();
            if value == "prod" {true} else {false}
        }
        _ => {
            app_config.merge(config::File::with_name("conf/application_dev")).unwrap();
            false
        }
    };
    app_config.merge(config::Environment::with_prefix("TL_APP")).unwrap();
    let server = HttpServer::new(move || App::new()
        .wrap(
            CookieSession::signed(&[0; 32]) // <- create cookie based session middleware
                .secure(is_prod),
        ).service(index));

    if is_prod  {

        let mut config = ServerConfig::new(NoClientAuth::new());
        let cert_file = &mut BufReader::new(File::open("./conf/cert.pem").unwrap());
        let key_file = &mut BufReader::new(File::open("./conf/key.pem").unwrap());
        let cert_chain = certs(cert_file).unwrap();
        let mut keys = rsa_private_keys(key_file).unwrap();
        config.set_single_cert(cert_chain, keys.remove(0)).unwrap();

        server.bind_rustls("127.0.0.1:8443", config)?
            .run()
            .await
    }else {
        server.bind("127.0.0.1:8088")?
            .run()
            .await
    }


}

代码已提交github  

现在还存在个问题: 现在是使用的println把日志信息输出到控制台的,这样日志信息多了很难查找,能不能把日志信息按照等级分类输入到文件中呢?

我们需要日志支持

添加日志支持

我们选用log4rs用于日志管理,因为跟Java开发中用到的log4j类似,方便上手。 首先在Cargo.toml文件中配置log和log4rs依赖,log相当于Java开发里用到的slf4j是一个日志门面(参考门面模式)。

og = "0.4"
log4rs="0.12"

在main函数中初始化log4rs

log4rs::init_file("conf/log4rs.yaml", Default::default()).unwrap();

conf/log4rs.yaml是日志配置文件与Java开发中的log4j.properties类似。接下来就可以把main.rs文件里的println!改成info!了 可以通过修改日志配置文件log4rs.yaml将不同级别的日志写入到不同文件中

当前程序已经具备了config和log,一般我们开发正式项目少不了跟数据库打交到,接下来我们尝试使用rust操作数据库。

集成ORM工具

Rust开发用什么ORM工具好呢?按照惯例,选一个github上star最多的。这次我选了Diesel。使用diesel把用户登录信息存储到数据库表中并添加用户注册接口。 至此这个Demo也算五脏俱全了。 可以把这个demo用于开始开发其它项目。 现在很多代码在main.rs文件里,显得有些臃肿不方便后续添加功能。

代码拆分

拆分后的项目目录结构:

rust_cms
├── common
│   ├── conf
│   ├── log
│   └── src
├── doc
├── site
│   └── src
├── target
│   ├── debug
│   └── doc
├── template
│   └── src
├── user
│   ├── conf -> ../common/conf
│   ├── migrations
│   └── src
└── web
    └── src

代码拆分方法可参考零基础学新时代编程语言Rust

拆好的代码放在另一个github仓库中 rust_cms