Desarrollo de herramientas de línea de comandos: comparación de Go y Rust

Este artículo explora mi experimento al escribir una pequeña herramienta de línea de comandos usando dos lenguajes en los que no tengo mucha experiencia en programación. Se trata de Go and Rust. Si no puede esperar para ver el código y comparar de forma independiente una versión de mi programa con otra, aquí está el repositorio de la versión Go del proyecto, y aquí está el repositorio de su versión escrita en Rust.











Descripción del proyecto



Tengo un proyecto de casa que llamé Hashtrack. Este es un sitio pequeño, aplicación de pila completa que escribí para una entrevista técnica. Es muy fácil trabajar con él:



  1. El usuario está autenticado (dado que ya se ha creado una cuenta).
  2. Introduce hashtags que quiere ver aparecer en Twitter.
  3. Espera a que aparezcan en la pantalla los tuits encontrados con el hashtag especificado.


Puedes probar Hashtrack aquí .



Después de completar la entrevista, continué trabajando en el proyecto por interés deportivo y me di cuenta de que puede ser una gran plataforma en la que puedo probar mis conocimientos y habilidades en el campo del desarrollo de herramientas de línea de comandos. Ya tenía un servidor, así que solo tenía que elegir un idioma en el que implementaría un pequeño conjunto de capacidades dentro de la API de mi proyecto.



Capacidades de la herramienta de línea de comandos



Aquí hay una descripción de las características principales, en particular, los comandos que quería implementar en mi herramienta de línea de comandos.



  • hashtrack login - iniciar sesión en el sistema, es decir, crear un token de sesión y guardarlo en el sistema de archivos local, en el archivo de configuración.
  • hashtrack logout — , — , .
  • hashtrack track <hashtag> [...] — .
  • hashtrack untrack <hashtag> [...] — .
  • hashtrack tracks — , .
  • hashtrack list — 50 .
  • hashtrack watch — .
  • hashtrack status — , .
  • --endpoint, .
  • --config, .
  • endpoint.


Aquí hay algunas cosas importantes a considerar acerca de mi herramienta antes de comenzar a trabajar en ella:



  • Debería usar la API del proyecto que usa GraphQL, HTTP y WebSocket.
  • Debe utilizar el sistema de archivos para almacenar el archivo de configuración.
  • Debería poder analizar argumentos posicionales y marcas de línea de comando.


¿Por qué decidí usar Go and Rust?



Hay muchos lenguajes en los que puede escribir herramientas de línea de comandos.



En este caso, quería elegir un idioma con el que no tenía experiencia, o un idioma con el que tenía muy poca experiencia. Además, quería encontrar algo que se compilara fácilmente en código de máquina, ya que esto es una ventaja adicional para una herramienta de línea de comandos.



El primer idioma, que es obvio para mí, vino a mi mente. Go. Probablemente esto se deba a que muchas de las herramientas de línea de comandos que utilizo están escritas en Go. Pero también tenía un poco de experiencia en la programación de Rust, y me pareció que este lenguaje también sería adecuado para mi proyecto.



Pensando en Go y Rust, pensé que puedes elegir ambos idiomas. Dado que mi objetivo principal era el autoaprendizaje, tal movimiento me brindaría una excelente oportunidad para implementar el proyecto dos veces y descubrir de forma independiente las ventajas y desventajas de cada uno de los idiomas.



Aquí me gustaría mencionar los idiomas Crystal y Nim . Parecen prometedores. Espero tener la oportunidad de probarlos en mi próximo proyecto.



Ambiente local



Antes de utilizar un nuevo conjunto de herramientas, siempre me interesa su usabilidad. Es decir, si tengo que usar algún tipo de administrador de paquetes para instalar programas globalmente en el sistema. O, lo que me parece una solución mucho más conveniente, si será posible instalar todo en función de la cuenta de usuario. Estamos hablando de gestores de versiones, simplifican nuestra vida, centrándose en instalar programas en los usuarios y no en el sistema en su conjunto. En el entorno de Node.js, NVM hace esto muy bien .



Al trabajar con Go, puede utilizar GVM para el mismo propósito . Este proyecto es responsable de la instalación del software local y el control de versiones. Instalarlo es muy sencillo:



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


Al preparar un entorno de desarrollo en Go, debe tener en cuenta la existencia de dos variables de entorno: GOROOTy GOPATH. Puedes leer más sobre ellos aquí .



El primer problema que enfrenté al usar Go fue el siguiente. Cuando traté de comprender cómo funciona el sistema de resolución de módulos y cómo se aplica GOPATH, me resultó bastante difícil configurar una estructura de proyecto con un entorno de desarrollo local funcional.



Terminé usando el directorio del proyecto GOPATH=$(pwd). La principal ventaja de esto fue que tenía un sistema para trabajar con dependencias a mi disposición, limitado por el marco de un proyecto separado, algo así como node_modules. Este sistema ha funcionado bien.



Una vez que terminé de trabajar en mi herramienta, descubrí que había un proyecto virtualgo que me ayudaría a resolver mis problemas GOPATH.



Rust tiene un instalador oficial de rustup que instala el kit de herramientas necesario para usar Rust. Rust se puede instalar literalmente con un comando. Además, cuando se utiliza rustup, tenemos acceso a componentes adicionales como el servidor rls y el formateador de código rustfmt . Muchos proyectos requieren compilaciones nocturnas de la caja de herramientas de Rust. Gracias a la aplicación rustup, no tuve problemas para cambiar de versión.



Soporte del editor



Estoy usando VS Code y pude encontrar extensiones tanto para Go como para Rust. Ambos idiomas están perfectamente soportados en el editor.



Para depurar el código de Rust, siguiendo este tutorial, necesitaba instalar la extensión CodeLLDB .



Gestión de paquetes



El ecosistema Go no tiene un administrador de paquetes ni siquiera un registro oficial. Aquí, el sistema de resolución de módulos se basa en la importación de módulos de URL externas.



Rust usa el administrador de paquetes Cargo para administrar las dependencias, que descarga paquetes de crates.io desde el registro oficial de paquetes de Rust. En paquetes de cajas ecosistema puede ser la documentación publicada en docs.rs .



Bibliotecas



Mi primer objetivo al explorar nuevos lenguajes fue descubrir qué tan difícil sería implementar una comunicación HTTP simple con un servidor GraphQL utilizando solicitudes y mutaciones.



Hablando de Go, logré encontrar varias bibliotecas como machinebox / graphql y shurcooL / graphql . El segundo usa estructuras para ordenar y desagrupar datos. Por eso la elegí a ella.



Bifurqué shurcooL / graphql ya que necesitaba personalizar el encabezado en el cliente Authorization. Este RP envía los cambios .



A continuación, se muestra un ejemplo de cómo invocar una mutación GraphQL escrita en Go:



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
}


Cuando usaba Rust, necesitaba usar dos bibliotecas para ejecutar consultas GraphQL. El punto aquí es que la biblioteca es graphql_clientindependiente del protocolo, su objetivo es generar código para serializar y deserializar datos. Por tanto, necesitaba una segunda librería ( reqwest), con la que organicé el trabajo con peticiones 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())),
    }
}


Ninguna de las bibliotecas Go y Rust admitía GraphQL sobre el protocolo WebSocket.



De hecho, la biblioteca graphql_clientadmite suscripciones, pero como es independiente del protocolo, tuve que implementar los mecanismos de interacción de WebSocket con GraphQL yo mismo.



Para usar WebSocket en la versión Go de la aplicación, se tuvo que modificar la biblioteca. Como ya usé una bifurcación de la biblioteca, no quería hacer esto. En su lugar, utilicé una forma simplificada de "ver" nuevos tweets. Es decir, para recibir tweets, envié solicitudes de API cada 5 segundos. No estoy orgulloso de haber hecho precisamente eso .



Al escribir programas en Go, puede utilizar la palabra clavegopara ejecutar corrientes ligeras llamadas goroutines. Rust usa subprocesos del sistema operativo, esto se hace llamando Thread::spawn. Los canales se utilizan para transferir datos entre transmisiones y allí y allí.



Procesamiento de errores



Go trata los errores de la misma forma que cualquier otro valor. La forma habitual de gestionar los errores en Go es comprobar si hay errores:



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 tiene una enumeración Result<T, E>que incluye valores que indican éxito o fracaso. Esto, respectivamente, Ok(T)y Err(E). Aquí hay otra enumeración Option<T>que incluye los valores Some(T)y None. Si está familiarizado con Haskell, entonces puede reconocer las mónadas Eithery sus significados Maybe.



También hay "azúcar sintáctico" en relación con la propagación de errores (operador ?), que resuelve el valor de la estructura Result, ya sea Optioncon retorno automático Err(...)o Nonesi algo va mal.



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())
}


Este código es equivalente al siguiente código:



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())
}


Entonces, Rust tiene lo siguiente:



  • Estructura monádica ( Optiony Result).
  • Soporte al operador ?.
  • Un rasgo que se Fromutiliza para convertir automáticamente los errores a medida que se propagan.


La combinación de las tres características anteriores nos da un sistema de manejo de errores que yo llamaría el mejor que he visto. Es simple y optimizado, y el código escrito con él es fácil de mantener.



Tiempo de compilación



Go es un lenguaje que se creó con la idea de que el código escrito en él se compilaría lo más rápido posible. Examinemos esta pregunta:



> 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


Impresionante. Ahora veamos qué nos mostrará 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


Todas las dependencias se compilan aquí, que son 214 módulos. Cuando reinicia la compilación, todo ya está preparado, por lo que esta tarea se realiza casi al instante:



> 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


Como puede ver, Rust usa un modelo de compilación incremental. Se realiza una recompilación parcial del árbol de dependencias, comenzando con el módulo modificado y terminando con los módulos que dependen de él.



La compilación de lanzamiento del proyecto lleva más tiempo, lo cual es bastante esperado, ya que el compilador optimiza el código en este caso:



> 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


Integración continua



Las características de compilación de proyectos escritos en Go y Rust, que identificamos anteriormente, aparecen, lo cual es bastante esperado, en el sistema de integración continua.





Ir al procesamiento del proyecto





Procesando un proyecto de Rust



Consumo de memoria



Para analizar el consumo de memoria de diferentes versiones de mi herramienta de línea de comandos, utilicé el siguiente comando:



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


El comando time -vmuestra mucha información interesante, pero estaba interesado en la métrica del proceso Maximum resident set size, que es la cantidad máxima de memoria física asignada a un programa durante su ejecución.



Aquí está el código que utilicé para recopilar datos de consumo de memoria para diferentes versiones del programa:



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


Estos son los resultados de la versión 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


Aquí está el consumo de memoria de la versión Rust del programa:



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


Esta memoria se asigna durante las siguientes tareas:



  • Interpretación de los argumentos del sistema.
  • Cargando y analizando el archivo de configuración desde el sistema de archivos.
  • Acceder a GraphQL a través de HTTP mediante TLS.
  • Analizando la respuesta JSON.
  • Escribiendo datos formateados en stdout.


Go y Rust tienen diferentes formas de administrar la memoria.



Go tiene un recolector de basura que se utiliza para detectar la memoria no utilizada y recuperarla. Como resultado, el programador no se distrae con estas tareas. Dado que el recolector de basura se basa en algoritmos heurísticos, usarlo siempre significa hacer concesiones. Normalmente, entre el rendimiento y la cantidad de memoria utilizada por la aplicación.



El modelo de gestión de la memoria de Rust tiene conceptos como propiedad, préstamo, vida útil. Esto no solo contribuye al manejo seguro de la memoria, sino que también garantiza un control completo sobre la memoria asignada en el montón sin requerir la administración manual de la memoria o la recolección de basura.



A modo de comparación, veamos otros programas que resuelven un problema similar al mío.



Mando Tamaño máximo del conjunto residente (kbytes)
heroku apps 56436
gh pr list 26456
git ls-remote (con acceso SSH) 6448
git ls-remote (con acceso HTTP) 23488


Razones por las que elegiría ir



Elegiría Go para algún proyecto por las siguientes razones:



  • Si necesitaba un idioma, sería fácil de aprender para los miembros de mi equipo.
  • Si quisiera escribir código simple a expensas de una menor flexibilidad del lenguaje.
  • Si estaba desarrollando software solo para Linux, o si Linux era el sistema operativo que más me interesaba.
  • Si el tiempo de compilación de proyectos fue importante.
  • Si necesitaba mecanismos maduros para la ejecución de código asincrónico.


Razones por las que elegiría Rust



Estas son las razones que podrían llevarme a elegir Rust para un proyecto:



  • Si necesitaba un sistema avanzado de manejo de errores.
  • Si quisiera escribir en un lenguaje de múltiples paradigmas que me permita escribir código más expresivo del que podría crear con otros lenguajes.
  • Si mi proyecto tuviera requisitos de seguridad muy altos.
  • Si el alto rendimiento era vital para el proyecto.
  • Si el proyecto apuntaba a múltiples sistemas operativos y me gustaría tener una base de código verdaderamente multiplataforma.


Observaciones generales



Go and Rust tiene algunas peculiaridades que todavía me persiguen. Estos son los siguientes:



  • Go está tan centrado en la simplicidad que a veces esta búsqueda tiene el efecto contrario (por ejemplo, como en los casos con GOROOTy GOPATH).
  • Todavía no entiendo realmente el concepto de "vida" en Rust. Incluso los intentos de trabajar con los correspondientes mecanismos del lenguaje me desequilibran.


Sí, quiero señalar que en las versiones más recientes de Go, trabajar con GOPATHya no causa problemas, por lo que debería transferir mi proyecto a una versión más nueva de Go.



Puedo decir que tanto Go como Rust son idiomas muy interesantes de aprender. Encuentro que son grandes adiciones a las posibilidades del mundo de la programación C / C ++. Le permiten crear aplicaciones para una amplia variedad de propósitos. Por ejemplo, servicios web e incluso, gracias a WebAssembly, aplicaciones web del lado del cliente .



Salir



Go y Rust son excelentes herramientas muy adecuadas para desarrollar herramientas de línea de comandos. Pero, por supuesto, sus creadores se guiaron por diferentes prioridades. Un lenguaje tiene como objetivo hacer que el desarrollo de software sea simple y accesible, de modo que el código escrito en ese lenguaje pueda mantenerse. Las prioridades del otro idioma son la racionalidad, la seguridad y el desempeño.



Si desea leer más sobre la comparación de Go vs Rust, eche un vistazo a este artículo. Entre otras cosas, plantea un problema relativo a problemas graves con la compatibilidad multiplataforma de programas.



¿Qué lenguaje usaría para desarrollar una herramienta de línea de comandos?






All Articles