使用Rust搭建Web開發環境

開始試用Rust的Web開發組件actix-web html

  • 本篇文章主要用於開發記錄,不對知識點作詳細講解。關於知識點的講解可參考零基礎學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" 前端

``` git

  1. 在src/main.rs文件中敲入如下代碼
    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
}github

4. 使用cargo run 運行程序 
5. 執行curl請求咱們編寫的login路徑

```bash
curl -v -H "Content-Type:application/json" -X POST --data '{"username":"tianalng", password:"tianlang"}' http://127.0.0.1:8088/login

沒有訪問成功:web

* 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

從返回的錯誤信息 ajax

400 Bad Request 算法

能夠看出這是由於客戶端請求不知足服務端也就是咱們寫的login服務要求形成的
通常看到4開始的http錯誤碼,咱們能夠認爲是客戶端沒寫好。若是是5開頭的能夠認爲是服務端沒寫好。 
也能夠搜索下:數據庫

在 ajax 請求後臺數據時比較常見。產生 HTTP 400 錯誤的緣由有:

一、前端提交數據的字段名稱或者是字段類型和後臺的實體類不一致,致使沒法封裝;
二、前端提交的到後臺的數據應該是 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告訴客戶端登陸失敗了。json

在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&lt;T&gt; AjaxResult&lt;T&gt; {
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對象:
```rust
#[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能夠換成i3二、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) -&gt; String {
let sign  = Blake2b::new()
.chain(b"change me every day")
.chain(text)
.result();
format!("{:X}", sign)

}

**注意生成簽名的時候咱們還添加了段文本"change me every day",能夠當作生成簽名使用的密碼,這段文本用戶是不知道的,並且會按期變動,這樣用戶就不能僞造cookie信息了**  
在login函數中使用sign函數生成簽名信息並在判斷用戶是否登陸時驗證簽名信息  
```rust
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() -&gt; std::io::Result&lt;()&gt; {
    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](https://github.com/TianLangStudio/rust_login)   

如今還存在個問題: 
如今是使用的println把日誌信息輸出到控制檯的,這樣日誌信息多了很難查找,能不能把日誌信息按照等級分類輸入到文件中呢?    

咱們須要日誌支持 
## 添加日誌支持  
咱們選用log4rs用於日誌管理,由於跟Java開發中用到的log4j相似,方便上手。 
首先在Cargo.toml文件中配置log和log4rs依賴,log至關於Java開發裏用到的slf4j是一個日誌門面(參考門面模式)。

og = "0.4"
log4rs="0.12"

在main函數中初始化log4rs 
```rust
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

相關文章
相關標籤/搜索