rust的web开发

2019-09-04 fishedee 后端

1 概述

rust的web开发体验,在学完了rust的基本知识以后,我们尝试来用rust写一个web的后端服务,代码在这里。我们将会对会与golang对比一下,rust的优势与劣势在哪里。

2 性能

Screen Shot 2019-09-04 at 9.13.45 P

在最新一轮的tenchempower的测试中,使用rust语言编写的web框架actix几乎是霸榜,在所有测试中都是第一名。在fortunes测试类型中,第一,二名actix甚至远远抛开第三名的C语言编写的web框架h2o,以golang编写的高度优化的fasthttp框架(第7名)仅仅只有它一半的性能。

为了验证一下actix框架的性能是否真的如此变态,我在本地测试了一下MyManager的golang版本的/user/search接口的性能,6400多qps。

然后,仅仅让MyManager的rust版本(同样的actix框架)跑在debug版本下,性能达到12000多qps,如果是release版本,性能是17000多qps,几乎是golang的三倍。

actix框架的吞吐量不仅恐怖,而且延迟也很变态,golang的平均延迟是41ms,而actix的平均延迟是5.91ms,简直就是全面碾压,变态得毫无道理,有兴趣的朋友可以下载代码直接编译运行测试一下。

3 类型系统

pub fn router(cfg:&mut web::ServiceConfig){
    cfg.route("/islogin",web::get().to_async(isLogin))
        .route("/checkin",web::post().to_async(checkIn));
}

fn isLogin(data:web::Data<WebData>,session:Session)->impl Future<Item=JsonResponse<userAo::User>,Error=Error>{
    let session = Arc::new(session);
    return loginAo::isLogin(&data.pool,&session)
        .map(|data|{
            JsonResponse::new(data)
        });
}

fn checkIn(data:web::Data<WebData>,form:web::Form<loginAo::LoginCheckIn>,session:Session)->impl Future<Item=JsonResponse<()>,Error=Error>{
    let session = Arc::new(session);
    return loginAo::login(&data.pool,&session,&form)
        .map(|data|{
            JsonResponse::new(data)
        });
}

写web业务时,最开始肯定是要写路由,就是将不同的handler挂在不同的path上面,就是图上的router函数中的to_async方法了。actix的路由设计也很直接,你的handler中包含什么类型,在路由的时候,就直接extract给你什么数据,也就是handler的参数不固定的。例如isLogin函数就两个参数,Data<WebData>和Session,而checkIn函数就三个参数,Data<WebData>,Session和Form<LoginCheckIn>。

func to_async(handler interface{})

同一个to_async的方法,能接受不同两个参数的handler,也可以接受三个参数的handler,更可以接受四,五个等等的不定参数的handler。很显然,在golang里面,你只能用interface{}作为入参。然后在to_async的实现里面,用reflect来动态调用handler方法。

但是,让我惊讶的是,rust里面竟然不需要反射就做到了to_async的实现,而且还能在编译时就检查到了你的handler有没有写对,这他妈就是有点牛逼了。这都得归功于rust设计的模板+trait的强类型系统,不仅灵活,而且很安全。

pub fn to_async<F, I, R>(handler: F) -> Route 
where
    F: AsyncFactory<I, R>,
    I: FromRequest + 'static,
    R: IntoFuture + 'static,
    R::Item: Responder,
    R::Error: Into<Error>, 

我们看一下,to_async的类型声明,它要求F要满足AsyncFactory的trait,同时AsyncFactory类型的模板参数I满足FromRequest的trait,R参数满足IntoFuture的trait。

pub trait AsyncFactory<T, R>: Clone + 'static
where
    R: IntoFuture,
    R::Item: Responder,
    R::Error: Into<Error>,
{
    fn call(&self, param: T) -> R;
}

AsyncFactory的trait很简单,就是要求能包含一个call方法,通过传入T以后,能返回R。

impl<Func, A, Res> AsyncFactory<(A), Res> for Func
    where Func: Fn(A) -> Res + Clone + 'static,
          Res: IntoFuture,
          Res::Item: Responder,
          Res::Error: Into<Error>,
    {
        fn call(&self, param: (A) -> Res {
            (self)(A.0)
        }
    }

然后Fn(A)->Res类型实现了AsyncFactory的trait,也就是单个参数的闭包实现了AsyncFactory

impl<Func, A,B, Res> AsyncFactory<(A,B), Res> for Func
    where Func: Fn(A,B) -> Res + Clone + 'static,
          Res: IntoFuture,
          Res::Item: Responder,
          Res::Error: Into<Error>,
    {
        fn call(&self, param: (A,B) -> Res {
            (self)(A.0,B.0)
        }
    }

同样Fn(A,B)->Res类型实现了AsyncFactory的trait,也就是两个参数的闭包实现了AsyncFactory。

同样地,actix指定了三个参数,四个参数等闭包都满足AsyncFactory,那么,在to_async实现里面,actix只需要执行call函数就调用起这些闭包了。由于这个过程都是用模板实现的,编译器在编译时会为不同类型的闭包生成不同的AsyncFactory的实现,也会生成不同的to_async函数实现,这实现了最佳的编译时优化(静态分发),完全没有运行时进行动态反射(动态分发)所带来的任何代价或损耗的。

4 宏

做web开发肯定离不开数据的序列化与反序列化了,在golang中,encoding/json包就是使用反射来实现json的序列化与反序列化,当然,为了性能,encoding/json包也作出了很多的努力。但是,总体性能依然不太理想。社区里面的easyjson使用go generate提前生成代码来优化这个问题。json-iterator则是使用unsafe的黑魔法来达到最佳的性能,总的来说,方法不是太漂亮。

pub fn to_string<T: ?Sized>(value: &T) -> Result<String> 
where
    T: Serialize, 

rust中使用宏解决这个问题。serde-json包,序列化的接口为to_string,它输入一个value的泛型参数,然后输出一个String,但是要求泛型参数T必须满足Serialize的trait。

use serde::ser::{Serialize, SerializeStruct, Serializer};

struct Person {
    name: String,
    age: u8,
    phones: Vec<String>,
}

// This is what #[derive(Serialize)] would generate.
impl Serialize for Person {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut s = serializer.serialize_struct("Person", 3)?;
        s.serialize_field("name", &self.name)?;
        s.serialize_field("age", &self.age)?;
        s.serialize_field("phones", &self.phones)?;
        s.end()
    }
}

对了一个普通的结构体Person类型,如果我们需要满足Serialize的trait,就需要实现一个serialize的泛型方法,然后对他的每个字段,执行一下serialize_field方法即可。很明显,这个方法对于不同的struct来说就是大同小异,相当机械化的代码。

use serde::ser::{Serialize, SerializeStruct, Serializer};

#[derive(Serialize)]
struct Person {
    name: String,
    age: u8,
    phones: Vec<String>,
}

所以,rust中提供了宏机制,在Person结构体添加一段注解就可以了。rust就会为Person提供默认的Serialize的trait的实现。其实,这种方法的实现与golang的go generate相当类似。但是与golang不同的是,它是在编译时执行的,不是在预处理时执行的,每次编译时,都会调用宏来生成新的代码。

//原来的代码
fn main(){
    let a = vec![1,2,3];
}

//宏展开后
fn main(){
    let a = {
        let temp = Vec::new();
        temp.push(1);
        temp.push(2);
        temp.push(3);
        temp
    }
}

另外一点是,宏生成的代码可以穿插到代码当中,开发者可以像使用函数一样调用一个宏,而不是像golang一样生成一个新的代码文件。另外,宏的机制允许你读取一个语法的ast树来生成新的代码,对于开发宏来说更加方便。

5 无GC

pub fn modPassword(db:&Pool,userModPassword:&data::UserModPassword)->impl Future<Item=(),Error=Error>{
    let newPassword = userModPassword.password.clone();
    let newDb = db.clone();
    return db::get(db,userModPassword.userId)
        .and_then(move|user|{
            return db::r#mod(&newDb,&data::UserMod{
                userId:user.userId,
                r#type:user.r#type,
                password:getPasswordHash(&newPassword),
                name:user.name,
            });
        });
}

rust的所有权和生命周期的机制保证了内存的安全性,内存都是在恰当的时候释放,不需要GC的介入。但是,这套机制的代价是代码复杂性大增,而且目前在配合使用future机制时,经常要不断地clone才能放进闭包里面,我觉得这个问题也挺困惑的,希望有不需要clone也能轻松放进闭包的解决方法。没有GC运行时的好处时,每个的请求的处理保证不会遇到Stop The World的问题,延时是软性可预测的,不会波动太大,而且长期内存占用更低。

6 异步

//使用future 0.1版本写的异步代码
pub fn search(db:&Pool,search:&data::UserSearch)->impl Future<Item=data::Users,Error=Error>{

    let (whereSql,whereArgv) = getWhere(&search);
    let dataSql = format!("select userId,name,password,type,createTime,modifyTime from t_user {} limit ?,?",whereSql);
    let mut dataArgv = whereArgv.clone();
    dataArgv.push(search.pageIndex.to_string());
    dataArgv.push(search.pageSize.to_string());

    let countSql = format!("select count(*) from t_user {}",whereSql);
    let countArgv = whereArgv;

    let conn = db.get_conn();
    return conn.and_then(move|conn|{
        return conn.prep_exec(dataSql,dataArgv).and_then(|data|{
            data.collect_and_drop::<(u64,String,String,u64,NaiveDateTime,NaiveDateTime)>()
        }).map(move|(conn, data)|{
            let rows = data.into_iter().map(|single|{
                return data::User{
                    userId:single.0,
                    name:single.1,
                    password:single.2,
                    r#type:single.3,
                    createTime:single.4,
                    modifyTime:single.5,
                };
            }).collect::<Vec<data::User>>();
            return (conn,rows);
        });
    }).and_then(move|(conn,rows)|{
            return conn.prep_exec(countSql,countArgv)
        .and_then(|data|{
            data.collect::<u64>()
        }).and_then(move|(_,mut data)|{
            let single = data.pop().unwrap();
            return ok(data::Users{
                data:rows,
                count:single,
            });
        })
    }).map_err(|e|{
        return Error::new(500,format!("{:?}",e));
    });
}

rust还很年轻,它为并发引入的异步编程的概念还没有一年的时间。目前的actix框架所用的异步框架还在future 0.1的阶段,而最广泛使用的ORM框架Diesel甚至还支持同步调用,这意味着它的性能在高并发环境还上不去。好不容易找到了一个mysql的异步连接库mysql_async的框架,然后发现它采用也是future 0.1的异步库。

//使用future 0.3版本的async/await语法糖写的异步代码
async fn go_next(y:&str)->String{
    let mut m = y.to_string();
    TimerFuture::new(Duration::new(2,0)).await;
    m.push_str("_cg");
    return m;
}

fn await_good()->impl Future<Output = u8>{
    async {
        let y = Rc::new("abc".to_string());
        let d = go_next(&y).await;

        borrow_y(&d);

        return 8;
    }
}

使用future 0.1的悲剧是,无法使用future 0.3版本的async/await的语法糖特性,只能手动and_then,map_err等闭包的方式来写代码,而且可怕的是,rust中future实现是强类型的,future链的编写需要相当繁琐和小心。另外,我在测试时也发现,如果在future链中进行panic时,就会发现这个崩溃堆栈没有一行是有意义的,真是让人无语。总的来说,rust的异步编程体验很差,而且周边生态的库支持也很不完善,堆栈信息也是乱七八糟的。

7 总结

rust现在写web的体验还很不好,但是一旦async/await稳定下来,周边生态也成熟的话,前景也是很看好的。与此同时,在学习rust的过程中,相互对比中,也发现了golang上的一些小缺陷:

  • 反射性能差。由于缺乏完善的类型推导系统,接口的参数需要接入灵活的类型时,就只能依靠interface{}参数,然后在接口的实现里面使用反射。而由于golang是编译型的语言,不像Java一样是解析型的语言,无法实现实时JIT,将反射在运行时实时编译为本地代码,这导致反射的效率比直接运行至少慢一个数量级。所以,编译型语言也不是万能的,它的反射性能就比解析型的语言差得多。
  • 延迟差,golang的调度器不是抢占式的,是协作式的。一个协程在调用函数,或者执行系统调用时,才会主动让出控制权。一旦一个协程执行比较慢,花费时间过多,就会直接堵塞绑定到这个核上面的所有协程,造成其他协程无法尽快处理请求并返回,也就是延迟较高。另外,golang的GC和Java的GC一样,虽然是并发式的,但是仍然有可能造成STW,造成请求里面的延迟高。值得一提的是,Erlang是唯一一个使用了GC,却依然实现了可预测的延迟,是软实时的语言,这点确实牛逼(这是建立在它的设计都是依赖于immutable数据类型上的,而且actor之间真正的完全隔离)。

没有一个语言是完美无暇的,我们应该尽可能了解不同语言的特点,然后根据不同的业务场景选择最适合的语言。

相关文章