命令行工具开发:比较Go和Rust

本文探索了我使用两种没有太多编程经验的语言编写小型命令行工具的实验。关于Go and Rust。 如果您迫不及待想要查看代码并独立比较程序的一个版本和另一个版本,则这里是项目的Go版本的存储库,是用Rust编写的版本的存储库。











项目概况



我有一个名为Hashtrack的家庭项目。这是我为技术面试编写的一个小型站点,全栈应用程序。使用它非常简单:



  1. 用户已通过身份验证(假设他已经为自己创建了一个帐户)。
  2. 他介绍了他想观看的主题标签出现在Twitter上。
  3. 他等待找到的带有指定主题标签的tweet出现在屏幕上。


您可以在此处测试Hashtrack



在完成采访后,出于体育兴趣,我继续从事该项目,并且我发现这可能是一个不错的平台,可以在此测试我在命令行工具开发领域的知识和技能。我已经有一个服务器,所以我只需要选择一种语言,就可以在我的项目的API中实现一小组功能。



命令行工具功能



这里是主要功能的描述,尤其是-我想在命令行工具中实现的命令。



  • hashtrack login -登录系统,即-创建会话令牌并将其保存在本地文件系统的配置文件中。
  • hashtrack logout — , — , .
  • hashtrack track <hashtag> [...] — .
  • hashtrack untrack <hashtag> [...] — .
  • hashtrack tracks — , .
  • hashtrack list — 50 .
  • hashtrack watch — .
  • hashtrack status — , .
  • --endpoint, .
  • --config, .
  • endpoint.


在开始使用该工具之前,需要考虑以下重要事项:



  • 它应该使用使用GraphQL,HTTP和WebSocket的项目API。
  • 它必须使用文件系统来存储配置文件。
  • 它应该能够解析位置参数和命令行标志。


为什么我决定使用Go and Rust?



您可以使用多种语言编写命令行工具。



在这种情况下,我想选择一种我没有经验的语言或一种我经验很少的语言。另外,我想找到一些可以轻松编译为机器代码的东西,因为这是命令行工具的新增功能。



对我来说,第一语言对我来说很明显。这可能是因为我使用的许多命令行工具都是用Go编写的。但是我在Rust编程方面也有一点经验,在我看来,这种语言也非常适合我的项目。



考虑到Go和Rust,我认为您可以选择两种语言。由于我的主要目标是自学,因此此举将为我提供一个极好的机会来两次实施该项目,并独立找出每种语言的优缺点。



在这里,我想提一下CrystalNim两种语言他们看起来很有前途。我期待着有机会在下一个项目中对其进行测试。



当地环境



在使用一套新工具之前,我总是对它的可用性感兴趣。即,是否必须使用某种程序包管理器在系统上全局安装程序。或者,对我来说,似乎更方便的解决方案是,是否可以根据用户帐户安装所有内容。我们谈论的是版本管理器,它们简化了我们的工作,侧重于在用户而非整个系统上安装程序。在Node.js环境中,NVM做得很好



使用Go时,您可以将GVM用于相同的目的。该项目负责本地软件的安装和版本控制。安装非常简单:



gvm install go1.14 -B
gvm use go1.14


在Go中准备开发环境时,您需要注意两个环境变量-GOROOT的存在GOPATH。您可以在此处阅读有关它们的更多信息



我使用Go遇到的第一个问题如下。当我试图了解模块解析系统如何工作以及如何应用时GOPATH,要建立一个具有功能性本地开发环境的项目结构是非常困难的。



我最终只是使用项目目录GOPATH=$(pwd)。这样做的主要好处是,我有一个可以处理依赖项的系统,受制于一个单独项目的框架,例如node_modules。该系统运行良好。



在使用完工具之后,我发现有一个virtualgo项目可以帮助我解决的问题GOPATH



Rust具有正式的rustup安装程序,该安装程序安装使用Rust所需的工具箱。可以用一个命令安装Rust。此外,在使用时rustup,我们可以访问其他组件,例如rls服务器rustfmt代码格式化程序许多项目需要每晚构建Rust工具箱。多亏了该应用程序rustup,我才可以在版本之间进行切换。



编辑器支持



我正在使用VS Code,并且能够找到针对Go和Rust的扩展。两种语言在编辑器中均得到完美支持。



要调试Rust代码,请按照教程进行操作,我需要安装CodeLLDB扩展名



包装管理



Go生态系统没有程序包管理器,甚至没有官方注册表。在此,模块解析系统基于从外部URL导入模块的基础。



Rust使用Cargo软件包管理器来管理依赖关系,后者从官方Rust软件包注册表中的crates.io下载软件包在生态系统包装箱中,可以将文件发布在docs.rs上



图书馆



我探索新语言的第一个目标是弄清楚使用请求和变异与GraphQL服务器实现简单HTTP通信会有多困难。



说到Go,我设法找到了一些库,例如machinebox / graphqlshurcooL / graphql。第二种使用用于封送和拆封数据的结构。因此,我选择了她。



我需要在客户端上自定义标头时分叉shurcooL / graphql Authorization。更改由PR提交



这是调用Go中编写的GraphQL突变的示例:



type creationMutation struct {
    CreateSession struct {
        Token graphql.String
    } `graphql:"createSession(email: $email, password: $password)"`
}

type CreationPayload struct {
    Email    string
    Password string
}

func Create(client *graphql.Client, payload CreationPayload) (string, error) {
    var mutation creationMutation
    variables := map[string]interface{}{
        "email":    graphql.String(payload.Email),
        "password": graphql.String(payload.Password),
    }
    err := client.Mutate(context.Background(), &mutation, variables)

    return string(mutation.CreateSession.Token), err
}


使用Rust时,我需要使用两个库来执行GraphQL查询。这里的重点是该库graphql_client独立于协议,旨在生成用于序列化和反序列化数据的代码。因此,我需要第二个库(reqwest),通过它我可以处理HTTP请求。



#[derive(GraphQLQuery)]
#[graphql(
    schema_path = "graphql/schema.graphql",
    query_path = "graphql/createSession.graphql"
)]
struct CreateSession;

pub struct Session {
    pub token: String,
}

pub type Creation = create_session::Variables;

pub async fn create(context: &Context, creation: Creation) -> Result<Session, api::Error> {
    let res = api::build_base_request(context)
        .json(&CreateSession::build_query(creation))
        .send()
        .await?
        .json::<Response<create_session::ResponseData>>()
        .await?;
    match res.data {
        Some(data) => Ok(Session {
            token: data.create_session.token,
        }),
        _ => Err(api::Error(api::get_error_message(res).to_string())),
    }
}


Go和Rust库均未通过WebSocket协议支持GraphQL。



实际上,该库graphql_client支持订阅,但是由于它是独立于协议的,因此我必须自己使用GraphQL实现WebSocket交互机制



要在应用程序的Go版本中使用WebSocket,必须修改该库。由于我已经使用了库的fork,所以我不想这样做。相反,我使用了一种“观看”新推文的简化方法。即,为了接收推文,我每5秒向API发送一次请求。我不感到自豪,我也只是那个



在Go中编写程序时,可以使用关键字go运行称为goroutines的轻量级流。Rust使用操作系统线程,这是通过调用来完成的Thread::spawn通道用于在流之间以及在那里和那里之间传输数据。



错误处理



Go与其他任何值一样对待错误。在Go中处理错误的通常方法是检查错误:



func (config *Config) Save() error {
    contents, err := json.MarshalIndent(config, "", "    ")
    if err != nil {
        return err
    }

    err = ioutil.WriteFile(config.path, contents, 0o644)
    if err != nil {
        return err
    }

    return nil
}


Rust的枚举Result<T, E>包含指示成功或失败的值。分别是Ok(T)Err(E)这里还有另一个枚举Option<T>,包括值Some(T)None如果您熟悉Haskell,则可以识别这些monadEither并具有这些含义Maybe



还有一个与错误传播(运算符?有关的“语法糖” ,它解析结构的值,Result或者Option自动返回,Err(...)或者None在出现问题时自动返回



pub fn save(&mut self) -> io::Result<()> {
    let json = serde_json::to_string(&self.contents)?;
    let mut file = File::create(&self.path)?;
    file.write_all(json.as_bytes())
}


此代码等效于以下代码:



pub fn save(&mut self) -> io::Result<()> {
    let json = match serde_json::to_string(&self.contents) {
        Ok(json) => json,
        Err(e) => return Err(e.into())
    };
    let mut file = match File::create(&self.path) {
        Ok(file) => file,
        Err(e) => return Err(e.into())
    };
    file.write_all(json.as_bytes())
}


因此,Rust具有以下内容:



  • 单子结构(OptionResult)。
  • 操作员支持?
  • 一种特征,From用于在错误传播时自动对其进行转换。


以上三个功能的结合为我们提供了一个错误处理系统,我称之为最好的错误处理系统。它既简单又精简,使用它编写的代码也易于维护。



编译时间



Go是一种以其编写的代码将尽快编译的想法创建的语言。让我们研究这个问题:



> time go get hashtrack #  
go get hashtrack  1,39s user 0,41s system 43% cpu 4,122 total

> time go build -o hashtrack hashtrack #  
go build -o hashtrack hashtrack  0,80s user 0,12s system 152% cpu 0,603 total

> time go build -o hashtrack hashtrack #  
go build -o hashtrack hashtrack  0,19s user 0,07s system 400% cpu 0,065 total

> time go build -o hashtrack hashtrack #      
go build -o hashtrack hashtrack  0,94s user 0,13s system 169% cpu 0,629 total


令人印象深刻。现在,让我们看看Rust将向我们展示什么:



> time cargo build
   Compiling libc v0.2.67
   Compiling cfg-if v0.1.10
   Compiling autocfg v1.0.0
   ...
   ...
   ...
   Compiling hashtrack v0.1.0 (/home/paulo/code/cuchi/hashtrack/cli-rust)
    Finished dev [unoptimized + debuginfo] target(s) in 1m 44s
cargo build  363,80s user 17,05s system 365% cpu 1:44,09 total


所有依赖项均在此处编译,共214个模块。当您重新开始编译时,一切都已经准备就绪,因此几乎可以立即执行此任务:



> time cargo build #  
    Finished dev [unoptimized + debuginfo] target(s) in 0.08s
cargo build  0,07s user 0,03s system 104% cpu 0,094 total

> time cargo build #      
   Compiling hashtrack v0.1.0 (/home/paulo/code/cuchi/hashtrack/cli-rust)
    Finished dev [unoptimized + debuginfo] target(s) in 3.15s
cargo build  3,01s user 0,52s system 111% cpu 3,162 total


如您所见,Rust使用增量编译模型。从修改后的模块开始到依赖于它的模块结束,将对依赖项树进行部分重新编译。



由于在这种情况下编译器会优化代码,因此项目的发布版本将花费更多的时间,这是可以预期的。



> time cargo build --release
   Compiling libc v0.2.67
   Compiling cfg-if v0.1.10
   Compiling autocfg v1.0.0
   ...
   ...
   ...
   Compiling hashtrack v0.1.0 (/home/paulo/code/cuchi/hashtrack/cli-rust)
    Finished release [optimized] target(s) in 2m 42s
cargo build --release  1067,72s user 16,95s system 667% cpu 2:42,45 total


持续集成



我们在上面确定的用Go和Rust编写的编译项目的功能在持续集成系统中很有希望出现。





进行项目处理





处理Rust项目



内存消耗



为了分析不同版本的命令行工具的内存消耗,我使用了以下命令:



/usr/bin/time -v ./hashtrack list


该命令time -v显示了很多有趣的信息,但是我对进程指示器感兴趣,进程指示器Maximum resident set size是在程序执行期间分配给程序的物理内存的峰值。



这是我用于收集程序不同版本的内存消耗数据的代码:



for n in {1..5}; do
    /usr/bin/time -v ./hashtrack list > /dev/null 2>> time.log
done
grep 'Maximum resident set size' time.log


这是Go版本的结果:



Maximum resident set size (kbytes): 13632
Maximum resident set size (kbytes): 14016
Maximum resident set size (kbytes): 14244
Maximum resident set size (kbytes): 13648
Maximum resident set size (kbytes): 14500


这是程序的Rust版本的内存消耗:



Maximum resident set size (kbytes): 9840
Maximum resident set size (kbytes): 10068
Maximum resident set size (kbytes): 9972
Maximum resident set size (kbytes): 10032
Maximum resident set size (kbytes): 10072


该内存是在以下任务期间分配的:



  • 系统参数的解释。
  • 从文件系统加载和解析配置文件。
  • 使用TLS通过HTTP访问GraphQL。
  • 解析JSON响应。
  • 将格式化的数据写入stdout


Go和Rust具有不同的内存管理方式。



Go有一个垃圾收集器,用于检测未使用的内存并将其回收。结果,程序员不会因这些任务而分心。由于垃圾收集器基于启发式算法,因此使用垃圾收集器总是意味着要做出让步。通常在性能和应用程序使用的内存量之间。



Rust的内存管理模型具有所有权,借用,生存期等概念。这不仅有助于安全的内存管理,而且可以确保完全控制堆上分配的内存,而无需手动进行内存管理或垃圾回收。



为了进行比较,让我们看一下解决与我的问题相似的其他程序。



命令 最大居民集大小(千字节)
heroku apps 56436
gh pr list 26456
git ls-remote (具有SSH访问权限) 6448
git ls-remote (具有HTTP访问权限) 23488


我选择Go的原因



我出于某些原因会选择Go,原因如下:



  • 如果我需要一种易于团队成员学习的语言。
  • 如果我想编写简单的代码,但要牺牲语言的灵活性。
  • 如果我只为Linux开发软件,或者Linux是我最感兴趣的操作系统。
  • 如果项目的编译时间很重要。
  • 如果我需要成熟的机制来执行异步代码。


我选择Rust的原因



以下是可能导致我为项目选择Rust的原因:



  • 如果我需要高级错误处理系统。
  • 如果我想用一种多范式语言编写,这使我可以编写比其他语言创建的更具表达力的代码。
  • 如果我的项目对安全性有很高的要求。
  • 高性能对项目是否至关重要。
  • 如果该项目针对许多操作系统,并且我想拥有一个真正的多平台代码库。


一般说明



Go和Rust仍然有些怪癖困扰着我。这些是以下内容:



  • 转到如此集中,有时这种追求具有相反的效果(例如,如在与箱子简单GOROOTGOPATH)。
  • 我仍然不太了解Rust中的“终身”概念。甚至尝试使用相应的语言机制也使我失去了平衡。


是的,我想指出的是,在新版本的Go中,使用它GOPATH不再会导致问题,因此我应该将项目转移到新版本的Go中。



我可以说Go和Rust都是非常有趣的语言。我发现它们是C / C ++编程世界的强大补充。它们使您可以创建多种用途的应用程序。例如,Web服务,甚至感谢WebAssembly,客户端Web应用程序



结果



Go和Rust是非常适合开发命令行工具的出色工具。但是,当然,他们的创作者受到不同优先级的指导。一种语言旨在使软件开发变得简单且易于访问,从而可以维护用该语言编写的代码。另一种语言的优先级是合理性,安全性和性能。



如果您想了解更多关于去锈VS比较,看看这个文章。除其他外,它引起了有关程序的多平台兼容性的严重问题。



您将使用哪种语言来开发命令行工具?






All Articles