Background Image
INGENIERÍA DE PLATAFORMAS

Lo que este desarrollador sénior aprendió de su primer gran proyecto en Rust

Construir un MVP de Rust IoT durante las vacaciones

January 17, 2024 | 6 Minuto(s) de lectura

Un poco de información sobre mí

  • según el organigrama de mi empresa en Workday, mi cargo actual es "Consultor Senior"

  • Llevo más de una década escribiendo código a tiempo completo en diversos puestos y unos cinco años desarrollando software profesionalmente.

  • En la escuela de posgrado, hice análisis y visualización de datos casi exclusivamente en C++.

  • durante los últimos cuatro años, mi lenguaje de desarrollo principal ha sido Scala

Esta mezcla de desarrollo "cercano al metal" en C++ y el desarrollo de estilo FP en Scala me ha llevado a Rust, que veo como un término medio bastante utilizable entre los dos. Así que durante el último año, he estado aprendiendo Rust mediante la construcción de pequeños proyectos y la dirección de clubes de lectura semanales.

Durante las vacaciones, decidí dar un paso más y construir mi primer "gran" proyecto Rust. He aquí cómo fue...

El proyecto

La idea que tenía era construir un pequeño sistema de Internet de las Cosas (IoT). Los objetivos explícitos del proyecto eran:

- Construir algunos servicios que utilizaran muy pocos recursos, capaces de ejecutarse en entornos donde el tamaño del Java Runtime Environment (JRE) haría imposible el desarrollo en Scala o Java.

- Los servicios deberían ejecutarse en nodos separados y de alguna manera descubrirse unos a otros en la red, sin necesidad de IPs codificadas.

- Los servicios deben poder enviarse mensajes (y recibirlos) entre sí.

- Debe haber algunos datos simulados en el sistema, que puedan visualizarse (o, al menos, exportarse a una hoja de cálculo para su visualización).

Además, trabajo para una empresa de consultoría, y el cliente con el que estamos comprometidos fue OOO durante las Navidades. Así que otro de los objetivos de este proyecto era tener todo esto terminado, desde cero, en sólo cinco días laborables.

Conseguí contratar a otros dos desarrolladores* que me ayudaron a sentar algunas de las bases del proyecto en esos cinco primeros días; en las dos semanas siguientes, he construido el resto del proyecto yo solo. En general, considero que el esfuerzo ha sido un éxito, pero espero que quien lea esto pueda dejar algún comentario valioso que pueda mejorar futuros esfuerzos de este tipo.

* Muchas gracias a boniface y davidleacock¡!

Planificación

Los otros dos desarrolladores y yo pasamos la semana anterior a Navidad planificando y discutiendo el proyecto, pero no programando. Esperábamos que un "equipo de tres desarrolladores construye un MVP de IoT Rust en sólo cinco días" fuera una herramienta de ventas eficaz para nosotros y nuestra empresa. Era muy ambicioso, y el trabajo pronto se extendió a unas cuatro semanas de persona en total (lo que no está nada mal, en mi opinión).

Me preparé escribiendo algunos bocetoscomo yo los llamaba. Se trataba de pequeños proyectos que (esperaba) se convirtieran en los cimientos de nuestro MVP. Estos bocetos incluyen

También he creado una imagen de contenedor personalizada basada en Rust para el proyecto de CD, que incluye las librerías necesarias para el proyecto CIcomo rustfmt para el formateo, clippy para linting, y grcov para los informes de cobertura de código.

Aunque originalmente pensé en contenerizar estas aplicaciones, ejecutarlas en Kubernetes (K8s), y dejar que K8s hiciera el descubrimiento de servicios, me di cuenta de que ese enfoque no cuadraría con la "vida real", donde los servicios de alguna manera tendrían que descubrirse unos a otros en una LAN. mDNS parecía la mejor opción para emular el descubrimiento de servicios de la vida real en una red.

Por último, teníamos que planificar el propio dominio. Se nos ocurrió algo muy parecido a este ejemplo de Bridgera.

Graphic 1 - What This Senior Developer Learned From His First Big Rust Project

1. A sensor recoge datos del entornoy de alguna manera comunica esos datos a...

2. a Controladorque evalúa esos datos y (opcionalmente) envía un comando a...

3. un Actuadorque tiene algún efecto sobre...

4. el Entornoque, en nuestro ejemplo, genera datos falsos y tiene un estado interno que puede ser modificado a través del Actuador Comandos

Estos cuatro tipos de Dispositivos -- Sensors, Actuadors, y el Controlador y Entornoson los servicios en este sistema. Se conectan entre sí a través de mDNS.

Como disponíamos de poco tiempo y recursos, todo esto debía hacerse por software, sin interacción real con ningún sensor o actuador de hardware. Por eso necesitábamos un Entornoque pudiera generar datos falsos para nuestros sensoress.

(Desde el principio nos dimos cuenta de que era importante disponer de un lenguaje ubicuo en torno a estos conceptos. Trabajamos para refinar y documentar nuestra comprensión del dominio, y mantener nuestro modelo lo más claro y reducido posible. Nada innecesario o confuso debía colarse).

Implementación de

En fin, al grano.

Espacio de trabajo de carga

Este proyecto está estructurado como un Espacio de trabajo Cargodonde hay múltiples cajas en un único repositorio. La idea detrás de esto era que, en un escenario de la vida real, querríamos publicar cajas de biblioteca separadas para Actuadors, Sensors, etc. Si eres un desarrollador externo que está creando software para (por ejemplo) una bombilla inteligente, puede que no te importe el código Sensor . Su dispositivo sólo tiene un efecto sobre el entorno, no lo sondea de ninguna manera.

Configurar un proyecto como un espacio de trabajo Cargo es sencillo, y le permite extraer código "común" en una o más cajas separadas, que se adhiere a la norma DRY y, en general, hace que todo el proyecto sea más fácil de desarrollar y mantener.

Dependencias

En aras de mantener los binarios y contenedores resultantes lo más pequeños posible, dirigí este proyecto lejos de los grandes frameworks (tokio, actixetc.), optando por "soluciones siempre que hemos podido. Actualmente, el proyecto sólo tiene ocho dependencias.

1. mdns-sd para la red mDNS

2. chrono para timestamps UTC y timestamp de/serialization

3. rand para la generación de números aleatorios

4. local-ip-address para el descubrimiento de la dirección IP local

5. phf para estática en tiempo de compilación Mapas

6. registro el rust-lang logging framework oficial

7. env_logger una implementación mínima de logging

8. plotly para graficar datos en la Web UI

Incluso algunos de estos no son estrictamente necesarios. Podríamos...

- Prescindir de chrono mediante nuestra propia serialización de marcas de tiempo.

- Eliminar phf creando un único Mapa en tiempo de ejecución

- Eliminar registro y env_logger volviendo a utilizar println!() en todas partes

mdns-sd y dirección-ip-local son críticas; aseguran que el Dispositivos de la red puedan conectarse entre sí. rand es fundamental para el Entornoy sólo aparece en las dependencias de ese crate. plotly es crítico para la Web UI, alojada por el Controladorque (en el momento de escribir esto) sólo muestra un gráfico en vivo y nada más.

Graphic 2 - What This Senior Developer Learned From His First Big Rust Project

Finalmente, para la contenedorización de servicios, usamos rust:alpine en una construcción multietapa. Sólo fue necesario instalar una única dependencia en la etapa inicial, musl-devque es necesaria para dirección-ip-local crate.

Los tamaños finales de los cuatro binarios producidos (para el controlador Controlador, Entornoy una implementación de cada uno de los Sensor y Actuador ) oscilaban entre 3,6 MB a 4,8 MBun orden de magnitud menor que el JRE, que ocupa entre alrededor de 50-100MBdependiendo de la configuración.

Los contenedores eran un poco más grandes, con unos 13,5 MB en 13,7 MB. Esto sigue siendo una miseria comparado con los tamaños de imagen de contenedor a los que estoy acostumbrado para proyectos basados en Scala -- encuentro que las imágenes de contenedor de Scala están típicamente en el rango de los 100s de MBs, así que < 15MB es un soplo de aire fresco.

Descubrimiento de servicios y mensajería

Como muestra este bocetoes realmente sencillo hacer que dos servicios se descubran mutuamente a través de mDNS con el comando mdns-sd . Una vez que los servicios se conocen entre sí, pueden comunicarse.

La forma más fácil que conozco para que dos servicios en una red se comuniquen entre sí es a través de HTTP. Así que en este proyecto...

- El Servicio A descubre el Servicio B a través de mDNS, recuperando su ServiceInfo

- El Servicio A abre un TcpStream conectándose al Servicio B usando la dirección extraída de su ServiceInfo

- cada servicio (incluido el Servicio B) abre un TcpListener a su propia dirección, a la escucha de conexiones TCP entrantes

- El Servicio A envía un mensaje al Servicio B a través de su TcpStreamy el Servicio B lo recibe en su TcpListenery envía una respuesta al Servicio A, cerrando el socket.

Estos mensajesno tienen por qué ser mensajes con formato HTTP, pero facilita la interacción con ellos "desde fuera" (mediante curl) si lo son.

Del mismo modo, los puntos de datos (llamados datoss en este proyecto) enviados a través de HTTP no necesitan necesitan serializarse en JSON, pero se hace porque facilita la interacción con esos datos en un navegador o en la línea de comandos.

La construcción de mensajes con formato HTTP y la de/serialización de JSON se ha hecho a mano en este repositorio, para evitar dependencias innecesarias.

CONSEJO: un "problema" que encontré al escribir el código de descubrimiento de servicios fue que cada servicio necesita su propio mDNS ServiceDaemon. En la demo original, se instanciaba un único demonio y se ejecutaba clone()y los clones se pasaban a cada servicio. Pero entonces sólo el Actuador (o sólo el Sensor) vería, por ejemplo, el Entorno en línea. Consumiría el ServiceEvent que anuncia el descubrimiento de ese dispositivo en la red, y el siguiente servicio no podría verlo entrar en línea. Así que, atención: crea un demonio separado para cada servicio que necesite escuchar eventos.

Patrones comunes y observaciones

Con la estructura básica del proyecto en su lugar, y con los servicios capaces de comunicarse, me di cuenta de algunos patrones recurrentes como el proyecto cobró vida.

Arco&lt;Mutex<Everything>&gt;

En este proyecto, el Dispositivotienen algún estado que a menudo se actualiza a través de hilos. Por ejemplo, el Controlador utiliza un subproceso para buscar constantemente nuevos Sensors y Actuadors en la red, y añade los que encuentra a su memoria.

Para actualizar de forma segura los datos compartidos a través de múltiples hilos, me encontré envolviendo un montón de cosas en Arco&lt;Mutex<...>&gt; siguiendo este ejemplo de El Libro.

Me interesaría saber si hay una forma mejor / más ergonómica / más idiomática de hacer esto.

Clonar antes de mover-ing a un nuevo hilo

Otro patrón que aparece unas cuantas veces en este repo es algo como...

fn my_method(&self) {
    let my_field = self.field.clone();
    std::thread::spawn(move || {
        // do something with my_field
    })
}

No podemos reordenar esto a...

fn my_method(&self) {
    std::thread::spawn(move || {
        let my_field = self.field; // will not compile
        // do something with my_field
    })
}

porque "Auto no puede ser compartido entre hilos de forma segura" (E0277). Del mismo modo, cualquier cosa envuelta en un Arco<...> necesita ser clonard también.

fn my_other_method(&self) {
    let my_arc = Arc::clone(&self.arc);
    std::thread::spawn(move || {
        // do something with my_arc
    })
}

He terminado con algunos hilo::spawn con grandes bloques de clond justo encima de ellos.

Hay un RFC para esta cuestión, que lleva abierta desde 2018. Parece que últimamente está progresando un poco, pero podría pasar un tiempo antes de que ya no necesitemos manualmente clonar todo lo que se moverd en un hilo.

Es demasiado fácil .unwrap()

Este proyecto no es muy grande -- son unas 5000 líneas de código Rust, según mi estimación. Pero en esas 5000 líneas .unwrap() aparece más de 100 veces.

Cuando se desarrolla algo nuevo, es más fácil (y más divertido) centrarse en el "camino feliz" y dejar el manejo de excepciones para después. Rust hace esto bastante fácil: asume el éxito, llama a .unwrap() en tu Opción o Resultadoy seguir adelante; es muy fácil saltarse la gestión de errores adecuada. Pero es un coñazo añadirlo más tarde (imagínese añadir la gestión de errores para todos esos más de 100 archivos .unwrap() ).

Sería mejor, en mi opinión, estar al tanto de estos .unwrap()s a medida que aparecen.

Hacia el final de este MVP, mientras contaba todos estos sitios en los que faltaba la gestión de errores, me encontré a mí mismo anhelando un clippy que no permitiera ninguna regla .unwrap()...

Resulta que ya existen unwrap_used y esperar_usado que se pueden utilizar para que se produzca un error si se llama a cualquiera de estos métodos. Definitivamente voy a activar estos lints en mis proyectos personales en el futuro, y espero que con el tiempo se conviertan en el valor predeterminado.

Análisis

Escribí mucho código de análisis personalizado.

Un patrón común que seguí fue impl Mostrar para algún tipo, y luego añadir una función pub fn parse() para convertir la versión serializada en el tipo apropiado.

Esta no es probablemente la mejor manera de hacer esto -- cadenas fáciles de usar para mostrar son cosas diferentes de representaciones serializadas compactas para el paso de mensajes y persistencia. Si tuviera que hacer esto de nuevo, probablemente usaría un crate como serde para la serialización, y guardaría impl Display para una representación de cadenas fácil de usar.

Además, hice mi propio enrutamiento. Cuando una solicitud HTTP se encontró en un TcpStreamcomprobaba manualmente la línea línea_inicial (algo como POST /comando HTTP/1.1) para enrutar al endpoint apropiado. En el futuro, podría dejar esto a un crate externo... tal vez algo como hiper.

pub structs deberían implementar PartialEq cuando sea posible

Creo que esta es probablemente una buena regla general para cualquier estructura pub implementar PartialEq cuando sea apropiado, para que los consumidores de tu crate puedan comprobar la igualdad. El ServiceInfo de mdns-sd no deriva de ParcialEq. Esto significa que no podía probar fácilmente la igualdad de dos ServiceInfos en las pruebas.

En lugar de esto, comprobé que cada pub en dos instancias devolviera los mismos valores. Esto era un poco pesado, resultando en grandes bloques de...

assert_eq!(actual.foo(), expected.foo());
assert_eq!(actual.bar(), expected.bar());
assert_eq!(actual.baz(), expected.baz());
// ...

Habría estado bien escribir

assert_eq!(actual, expected)

en su lugar, traits implementando otros traits puede ser un lío rápidamente.

En este proyecto, hay un trait Dispositivo con un método abstracto llamado get_handler()

// examples in this section are abridged for clarity
pub trait Device {
    fn get_handler(&self) -> Handler;
}

El Sensor y Actuador traitimplementan Dispositivoy proporcionan implementaciones por defecto de get_handler()

pub trait Sensor: Device {
    fn get_handler(&self) -> Handler {
        // some default implementation here for all `Sensor`s
    }
}
pub trait Actuator: Device {
    fn get_handler(&self) -> Handler {
        // some default implementation here for all `Actuator`s
    }
}

Pero luego están las implementaciones concretas de Sensor y Actuador

pub struct TemperatureSensor {
  // ...
}

impl Sensor for TemperatureSensor {}

impl Device for TemperatureSensor {
    fn get_handler(&self) -> Handler {
        Sensor::get_handler(self)
    }
}
pub struct TemperatureActuator {
  // ...
}

impl Actuator for TemperatureActuator {}

impl Device for TemperatureActuator {
    fn get_handler(&self) -> Handler {
        Actuator::get_handler(self)
    }
}

Ya existe una implementación concreta de get_handler() en Sensor / Actuadorpor lo que no necesitamos nada en la directiva impl Sensor / impl Actuador (a menos que haya otros métodos abstractos), pero sí necesitamos necesitamos este incómodo impl Dispositivo en cada caso.

En cuanto a Device "sabe TemperatureActuator no ha implementado su método abstracto. Pero nosotros sabemos que Actuador y que ActuadorTemperatura implementa Actuador. Parece que falta alguna información aquí que el compilador podría rellenar, teóricamente, pero actualmente no lo hace.

Rust podría utilizar un método .join() más robusto

Otros lenguajes permiten especificar un inicio y final al unir un array de cadenas, por lo que se podría hacer fácilmente algo como

["apple", "banana", "cherry"].join("My favourite fruits are: ", ", ", ". How about yours?")
//                                 |--------- start ---------| |sep|  |------- end -------|

que daría como resultado una cadena como "Mis frutas favoritas son: manzana, plátano y cereza. ¿Y las tuyas?"pero Rust todavía no tiene esta funcionalidad. Esto sería una pequeña gran adición de calidad de vida al tipo primitivo slice.

Todos mis Resultado son Cadenas

Esta es ciertamente la forma más fácil de construir rápidamente algo mientras se ignoran los fallos, pero en algún momento, debería volver atrás y reemplazarlos con tipos de error apropiados. Los clientes deberían ser capaces de encontrar el tipo de error, en lugar de tener que analizar el mensaje para averiguar qué falló.

Cualquier Resultado que se filtren al mundo externo (a los clientes) deberían tener los tipos Err y no sólo Cadena mensajes. Esta es otra cosa que me gustaría que clippy tuviera un lint para: no &amp;str o Cadena Err tipos.

S: En<String> en lugar de &amp;str

Rust coacciona automáticamente &amp;cadenas a &amp;strsy por lo tanto la sabiduría tradicional es que los argumentos de función deben ser del tipo &amp;strpara que el usuario no tenga que construir un nuevo cadena para pasarlo a una función que toma un argumento de cadena. Si ya tiene un cadenabasta con llamar a as_ref() para obtener un &amp;str.

Pero Rust sólo hará una coerción implícita a la vez. Así que no podemos convertir un tipo T: Into<String> en un tipo Cadena y luego en &amp;str. Por eso he optado por S: Dentro de<String> en lugar de &amp;str en algunos lugares. &amp;str implementa en<String> y lo mismo ocurre con cualquier tipo que implemente Into<String> (o Mostrar).

Es definitivamente menos performante, ya que estamos copiando datos en el heap, pero también un poco más ergonómico, ya que no necesitamos pasar t.to_string().as_ref() (cuando t: T y T: Into<String>) a la función, sino sólo t en sí.

Al parecer, tampoco soy la primera persona que descubre este patrón: En<String> arroja 176.000 resultados en GitHub.

Conclusión

He aprendido mucho en la construcción de este proyecto: acerca de las redes mDNS, el meollo de los formatos de mensajes HTTP, y escribir proyectos más grandes en Rust. Para resumir los puntos que he planteado más arriba...

Cosas que sé que necesito hacer mejor:

- No debería usar Mostrar para la serialización. En el futuro, miraré de usar un crate como serde en su lugar.

- No debería usar String para todos mis Err variantes. Los clientes de las bibliotecas que estoy produciendo deberían ser capaces de manejar un error sin tener que analizar un mensaje de cadena. En el futuro, construiré errores enums tan pronto como empiece a producir errores.

Cosas que espero de la comunidad Rust:

- Clonar clonar-antes de un mover el cierre es un dolor. Estoy siguiendo este tema de GitHub con la esperanza de que esto sea más ergonómico en el futuro.

- A clippy para Cadena / &amp;str Err también estaría bien.

- A Rust le vendría bien un .join() más robusto para trozos de cadena, con inicio y fin parámetros. Hasta donde yo sé, este tema aún no está siendo rastreado. Después de publicar este artículo, espero abrir una RFC para esta pequeña característica.

- Espero que con el tiempo, el compilador sea lo suficientemente inteligente como para saber cuándo B: A y C: Bdonde A define algún método abstracto y B implementa ese método abstracto, que c: C ya tiene ese método implementado, sin tener que decirle explícitamente al compilador acerca de esa implementación. Pero eso puede estar muy lejos.

Cosas sobre las que todavía tengo preguntas:

- ¿Es Arc&lt;Mutex<Everything>&gt; ¿es realmente la mejor manera de mutar datos a través de múltiples hilos? ¿O hay una forma más idiomática (o segura) de hacerlo?

Cosas que recomendaría a otros desarrolladores de Rust:

- Por favor impl PartialEq en cualquier pub publicado por su crate, siempre que sea posible. Sus clientes se lo agradecerán (ojalá).

- No tenga miedo de utilizar S: Into<String> en lugar de &amp;str. Puede que tenga menos rendimiento, pero también es más ergonómico, y seguro que no eres la primera persona que lo hace.

- Habilitar clippy's unwrap_used y expect_used de clippy, para obligarte a afrontar los errores de frente, en lugar de dejarlos a un lado para resolverlos más tarde.

Si tiene algún comentario sobre este artículo, envíelo a la dirección de correo electrónico que figura en mi CV. Ha sido una experiencia de aprendizaje fantástica y estoy ansioso por seguir desarrollando Rust en serio en un futuro próximo.

¿Quieres saber más? Ponte en contacto con Improving sobre cualquier tema relacionado con Rust.

Ingeniería de plataformas
Desarrollo de software
Tecnología

Reflexiones más recientes

Explore las entradas de nuestro blog e inspírese con los líderes de opinión de todas nuestras empresas.
Tomorrow Technology Today Blog Thumbnail
IA/ML

Ingeniería de plataformas de IA escalables: Estrategias de sostenibilidad y reutilización

Cómo construir plataformas de IA escalables que garanticen la sostenibilidad, la colaboración y la eficiencia en su organización.