"Oxy hóa" nền tảng IoT nông nghiệp bằng Rust

Trong giới lập trình, "oxy hóa" là một cách nói vui ám chỉ việc viết lại (một phần hoặc toàn bộ) một phần mềm bằng ngôn ngữ Rust, đây là một lối chơi chữ, vì "Rust" còn có nghĩa là "rỉ sét", một hiện tượng do sự oxy hóa gây nên. Gần đây mình cũng mạnh dạn oxy hóa một phần nền tảng IoT nông nghiệp của AgriConnect.

Động lực khiến mình viết lại nền tảng IoT của AgriConnect bằng Rust là để giảm tải hệ thống, tăng cường khả năng chịu áp lực trong tương lai. Phần mềm mình đang nói đến ở đây có tên mã là "Hạt Thóc". Nghe tên khiêm tốn, nhỏ bé thôi nhưng nó vận hành theo kiểu SaaS (Software as a Service), tức một phần mềm sẽ vận hành cùng lúc nhiều trang trại khách hàng. Mỗi khách hàng sẽ có một không gian riêng khi thao tác, quản lý trang trại của mình, thậm chí có tên miền riêng, nhưng thực ra tất cả đều đang được phục vụ bởi một chương trình trên server. Phần mềm này vốn được viết bằng ngôn ngữ Python, framework Django, được chia ra nhiều thành phần, mỗi thành phần chạy dưới dạng một process, một service riêng. Trong hoàn cảnh đặc thù của "Hạt Thóc" thì thì mình không "oxy hoá" theo kiểu, viết lại một vài hàm nào đó bằng Rust, biên dịch dưới dạng thư viện, rồi dùng Python import thư viện đó, mà viết lại toàn bộ thành phần con luôn. "Hạt Thóc" có ba thành phần chính:

  • Collector: Giao tiếp để thu thập dữ liệu cảm biến, trạng thái bật tắt của các tải, và lưu vào database.
  • ControlView: Cung cấp giao diện web để người dùng vào xem dữ liệu, cấu hình trang trại, đặt lịch, hay bật tắt tải bằng tay.
  • ControlCenter: Chạy ngầm để phân tích lịch, dữ liệu cảm biến để ra lệnh bật, tắt tải, kiểm tra tình trạng bất thường và phát đi cảnh báo.

Thành phần chạy nặng nhất là ControlCenter vì phải tính toán nhiều và khẩn trương, do có những trang trại, cụ thể là thủy canh, có những luật điều khiển phức tạp. Khẩn trương là vì, cũng lại trang trại thủy canh, có những máy móc mà chỉ được bật trong 1 - 2 giây rồi tắt liền. Đây cũng là thành phần đáng được viết lại bằng Rust.

Để khỏi hiểu lầm về khả năng của Python, xin được giải thích thêm là việc viết lại bằng Rust này không cấp thiết lắm, vì bên mình vẫn chưa dùng đến server mạnh. Để tăng cường khả năng đáp ứng thì phương án đơn giản hơn là nâng cấp cấu hình server thôi, vì dư địa mở rộng vẫn còn nhiều. Việc mình chọn phương án viết lại chỉ để rèn luyện bản thân và để tiết kiệm năng lượng tiêu thụ, giảm phát thải carbon 😜.

Những bài toán nảy sinh khi một bộ code Rust cần cộng tác với một bộ code Django

Vì hoạt động chung với bộ code Django đang có nên controlcenter-rs phải đáp ứng những yêu cầu về interoperate như sau:

  • Hiểu được hệ thống cấu hình (settings).
  • Truy xuất được database đã được định hình bởi Django.

Các dự án Django của mình thường được quản lý settings bằng các file TOML, theo quy ước của thư viện Dynaconf, nghĩa là:

  • Settings được phân nhóm cho bốn môi trường: development (giai đoạn viết code trên máy cá nhân), staging (lúc chạy trên server staging phục vụ cho test), production (chạy thật để phục vụ cho người dùng thật), testing (khi chạy unittest /integration test). Để tránh việc lặp đi lặp lại thì có một môi trường default định nghĩa tất cả các settings gốc cùng giá trị gốc. Trong mỗi môi trường sau đó, cần thay đổi gì so với gốc thì chỉ cần định nghĩa lại mỗi tùy chọn đó thôi. Có một file dùng chung cho mọi lập trình viên, thông thường được đặt tên settings.toml, được lưu trong Git và có vừa đủ settings để chạy.

  • Mỗi lập trình viên trong quá trình viết và chạy thử trên máy cá nhân, có thể cũng cần những thay đổi nhỏ về settings cho phù hợp với máy mình, thì sẽ định nghĩa lại trong một file settings local, cũng theo cấu trúc giống như trên, nhưng không được lưu trong Git.

  • Tất cả các thông tin nhạy cảm như mật khẩu, API key thì không lưu vào các file trên kia, mà lưu trong file ẩn .secrets.toml, để giảm nguy cơ bị lộ, và không lưu trong Git để tránh việc rò rỉ trên các kho lưu mã nguồn.

Ví dụ của việc phân settings ra theo môi trường là:

  • Trong môi trường staging, production, mình ưu tiên kết nối database trong chế độ "không password", và qua Unix domain socket, do database nằm trong cùng server với ứng dụng web, và không mở cổng để nhận kết nối từ bên ngoài vào. Trong môi trường này thì URL của database rất ngắn gọn như postgres:///my_db.

  • Trong môi trường testing thì vì chạy trong Docker container, như là một phần của hoạt động CI/CD, với database nằm trong một container riêng, thì lại cần settings khác, để kết nối database qua TCP và hostname cụ thể.

  • Trên máy cá nhân, nếu lập trình viên sử dụng Linux thì có thể họ sẽ muốn kết nối database qua Unix domain socket, nếu lập trình viên dùng hệ điều hành khác thì sẽ kết nối qua TCP v.v...

Khi viết lại ControlCenter bằng Rust thì mình cần một thư viện hỗ trợ nạp settings từ các file cấu hình này. Nhưng không may là thư viện bên Rust chưa phong phú bằng bên Python. Mình chỉ tìm thấy hydroconf với tính năng gần bằng, nên mình phải fork nó ra và viết thêm code để có tính năng như mong muốn, bản sửa đổi nằm ở đây.

Về truy cập chung database với Django thì mình vẫn để Django đóng vai trò chính trong việc thiết kế model, quản lý migrations. Bên Rust thì chỉ cần định nghĩa model nương theo cấu trúc đã định ra bởi code bên Django. Về ORM (Object-Relational Mapping) thì bên Rust có hai thư viện nổi bật: DieselSeaORM. Sau khi thử nghiệm thì chỉ Diesel dùng được, do "Hạt Thóc" có dùng vài kiểu dữ liệu đặc biệt của PostgreSQL, như kiểu Point của lĩnh vực GIS (hệ thống thông tin địa lý), kiểu Interval, mà chỉ Diesel mới hỗ trợ.

Số lượng bảng database nhiều, viết code lại thì rất ngại, may thay Diesel có những công cụ giúp sinh ra code cho models từ dữ liệu có sẵn, bao gồm:

  • diesel_cli: Để sinh ra code cho schema.rs. Đây là code phục vụ cho "query builder".

  • dsync: Để sinh ra code cho các struct, dùng cho ORM.

Ví dụ một model bên code Django:

class ConditionSchedule(models.Model):
    crop = ForeignKey(Crop, on_delete=CASCADE)
    room = ForeignKey(Room, on_delete=CASCADE)
    cond_type = CharField(
        max_length=4,
        choices=BasicControlledConditionType.choices,
        verbose_name=_('condition type'),
        blank=True,
        null=True,
    )
    value_min = DecimalField(max_digits=6, decimal_places=1, default=25)
    value_max = DecimalField(max_digits=6, decimal_places=1, default=28)
    starting_at = DateTimeField(blank=True, null=True)
    ending_at = DateTimeField(blank=True, null=True)

Code schema.rs sinh bởi diesel_cli:

diesel::table! {
   farm_conditionschedule (id) {
       id -> Int8,
       #[max_length = 4]
       cond_type -> Nullable<Varchar>,
       value_min -> Numeric,
       value_max -> Numeric,
       starting_at -> Nullable<Timestamptz>,
       ending_at -> Nullable<Timestamptz>,
       crop_id -> Int8,
       room_id -> Int8,
   }
}

diesel::joinable!(farm_conditionschedule -> farm_crop (crop_id));
diesel::joinable!(farm_conditionschedule -> farm_room (room_id));

Code cho struct, sinh ra bởi dsync:

#[derive(
    Debug, Serialize, Deserialize, Clone, Queryable, Insertable, AsChangeset, Identifiable, Associations, Selectable,
)]
#[diesel(table_name=farm_conditionschedule, primary_key(id), belongs_to(Crop, foreign_key=crop_id) , belongs_to(Room, foreign_key=room_id))]
pub struct ConditionSchedule {
    pub id: i64,
    pub cond_type: Option<String>,
    pub value_min: BigDecimal,
    pub value_max: BigDecimal,
    pub starting_at: Option<DateTime<Utc>>,
    pub ending_at: Option<DateTime<Utc>>,
    pub crop_id: i64,
    pub room_id: i64,
}

Ngoài ra, mình cũng viết thêm công cụ Python để sinh ra code cho Rust, để chia sẻ một số dữ liệu từ bên code Python qua.

Lấy ví dụ, xét field cond_type của model ConditionSchedule phía trên, nó được lưu giá trị kiểu string, nhưng không phải chuỗi bất kì mà chỉ được nhận một trong những giá trị như 'ate', 'ahu', 'smo', v.v... theo định nghĩa sau:

from django.db.models import TextChoices


class BasicControlledConditionType(TextChoices):
    temperature = ('ate', _('temperature'))
    humidity = ('ahu', _('humidity'))
    moisture = ('smo', _('moisture'))
    ... 

Bên Django thì mình dùng class TextChoices để định nghĩa giới hạn lựa chọn như vậy. Bên Rust thì mình muốn dùng enum, thậm chí còn tiến thêm một bước nữa, là dựa vào type system của Rust để ràng buộc dữ liệu trao đổi giữa các hàm luôn. Lấy ví dụ mình có một hàm như sau, để tính toán xem tại thời điểm hiện tại, có cần giảm một điều kiện môi trường (nhiệt độ, độ ẩm) hay không:

pub fn should_decrease_condition(
    cond_type: &str,
    values: &[f64],
    .. 
) -> Option<bool> {}

Vì field ConditionSchedule.cond_type lưu giá trị string nên một cách tự nhiên, ta sẽ định nghĩa tham số cond_type trong hàm should_decrease_condition để nhận kiểu string. Nhưng như thế, ta phải giải quyết tình huống bên ngoài truyền vào nó một chuỗi bất kì không thuộc những lựa chọn trên kia, như thế sẽ làm code dài dòng và lặp đi lặp lại. Bằng cách định nghĩa một enum, giả sử đặt tên là BasicControlledConditionType, ta sẽ đảm bảo được bên ngoài chỉ truyền vào một trong những chuỗi mà ta mong muốn:

pub enum BasicControlledConditionType {
	Temperature,
	Humidity,
	Moisture,
}

pub fn should_decrease_condition(
    cond_type: BasicControlledConditionType,
    values: &[f64],
    .. 
) -> Option<bool> {}

Và cũng để đảm bảo cond_type có giá trị đúng ngay khi lưu xuống / đọc lên từ database, thì ta sẽ định nghĩa lại struct như sau:

#[derive(
    Debug, Serialize, Deserialize, Clone, Queryable, Insertable, AsChangeset, Identifiable, Associations, Selectable,
)]
#[diesel(table_name=farm_conditionschedule, primary_key(id), belongs_to(Crop, foreign_key=crop_id) , belongs_to(Room, foreign_key=room_id))]
pub struct ConditionSchedule {
    pub id: i64,
    pub cond_type: Option<BasicControlledConditionType>,
    pub value_min: BigDecimal,
    pub value_max: BigDecimal,
    pub starting_at: Option<DateTime<Utc>>,
    pub ending_at: Option<DateTime<Utc>>,
    pub crop_id: i64,
    pub room_id: i64,
}

Tuy nhiên, tới đây thì ta sẽ thấy rằng Rust từ chối biên dịch đoạn code trên, với một trong những lỗi sau:

required for `BasicControlledConditionType` to implement `AsExpression<diesel::sql_types::Nullable<diesel::sql_types::Text>>`

Điều này là vì định nghĩa sau:

enum BasicControlledConditionType {
	Temperature,
	Humidity,
	Moisture,
}

quá sơ sài khiến Diesel không biết phải chuyển hóa nó thành chuỗi gì khi lưu vào cột có kiểu Varchar, cũng như khi đọc một chuỗi từ database lên thì gò ép thành BasicControlledConditionType như thế nào.

Để hướng dẫn Diesel xử lý như thế nào, ta sẽ implement các trait để chuyển đổi dữ liệu cho enum.

Đầu tiên là chuyển đổi qua lại giữa enum và string. Không như Python, Rust chưa cho phép dùng string để làm giá trị tương đương cho từng variant, nghĩa là chưa cho phép viết:

enum BasicControlledConditionType {
	Temperature = "ate",
	Humidity = "ahu",
	Moisture = "smo",
}

nên để chuyển từ enum sang string, ta phải implement trait Display, kiểu kiểu như sau:

use std::fmt::{self, Display};

impl Display for BasicControlledConditionType {
	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
		let s = match self {
			Self::Temperature => "ate",
			Self::Humidity => "ate",
			Self::Moisture => "smo"
		};
		formatter.pad(s)
    }
}

Nhưng viết code như vầy thì dài quá, nhất là khi enum có nhiều variant, nên ta sẽ dùng thư viện strum:

use std::string::ToString;
use strum_macros::{Display, EnumString};

#[derive(Debug, Display, EnumString)]
enum BasicControlledConditionType {
	#[strum(serialize = "ate")]
	Temperature,
	#[strum(serialize = "ahu")]
	Humidity,
	#[strum(serialize = "smo")]
	Moisture,
}

Sau khi đã có trait để chuyển đổi qua lại giữa enum và string rồi, ta cần implement thêm các trait FromSql, ToSql để giúp Diesel biết cách chuyển đổi với kiểu dữ liệu của hệ CSDL (ở đây là PostgreSQL). Vì lười viết code nên mình lại dùng thư viện diesel_sqltype_enum_pg. Thế là, để có đầy đủ các trait thì enum của mình thành ra như vầy:

use diesel::deserialize::{FromSql, FromSqlRow};
use diesel::expression::AsExpression;
use diesel::serialize::ToSql;
use diesel::sql_types::Text;
use diesel_sqltype_enum_pg::FromToSql;
use serde::{Deserialize, Serialize};
use strum_macros::{AsRefStr, Display, EnumString};

[derive(
    Debug,
    Copy,
    Clone,
    PartialEq,
    Default,
    AsRefStr,
    EnumString,
    Display,
    Deserialize,
    Serialize,
    FromToSql,
    FromSqlRow,
    AsExpression,
)]
#[fromtosql(sql_type = Text)]
#[diesel(sql_type = Text)]
pub enum BasicControlledConditionType {
    #[default]
    #[strum(serialize = "ate")]
    Temperature,
    #[strum(serialize = "ahu")]
    Humidity,
    #[strum(serialize = "smo")]
    Moisture,
}

Nhưng như thế vẫn còn lười, vì có khá nhiều enum cần định nghĩa, và vì những thông tin như giá trị chuỗi, tên của variant đều có thể suy ra từ các class bên Python rồi, nên mình viết luôn một công cụ để sinh ra code định nghĩa các enum cho Rust, từ các class bên Python, Django.

Code gen

Một số mẹo gia tăng DX (developer experience)

Có một điều đáng chia sẻ thêm, là một mẹo khiến các field thời gian tự động chuyển đổi múi giờ khi lấy từ database lên. Điều này giúp khi đọc log, ta đỡ phải xoắn não chuyển đổi. Chẳng hạn đoạn log sau mình đang debug giá trị của ActuatorSchedule, mình muốn thấy field starting_at thể hiện theo múi giờ Việt Nam.

log

Để làm như trên thì ta dựa vào tính năng deserialize_as của Diesel. Trước hết cần định nghĩa hai kiểu dữ liệu trung gian:

use chrono::{DateTime, FixedOffset, Utc};
use chrono_tz::Asia::Ho_Chi_Minh;
use diesel::deserialize::Result as DieselDeResult;
use diesel::pg::Pg;
use diesel::prelude::*;
use diesel::sql_types::{Nullable, Timestamptz};
use serde::Deserialize;

// Intermediate types to help convert datetime loaded from DB to Viet Nam timezone

#[derive(Debug, Clone, Deserialize)]
pub struct VnDateTime(pub DateTime<Utc>);

#[derive(Debug, Clone, Deserialize)]
pub struct OptionalVnDateTime(Option<DateTime<Utc>>);

impl VnDateTime {
    pub fn now() -> Self {
        Self(Utc::now())
    }
}

impl Queryable<Timestamptz, Pg> for VnDateTime {
    type Row = DateTime<Utc>;

    fn build(row: Self::Row) -> DieselDeResult<Self> {
        Ok(VnDateTime(row))
    }
}

impl Queryable<Nullable<Timestamptz>, Pg> for OptionalVnDateTime {
    type Row = Option<DateTime<Utc>>;

    fn build(row: Self::Row) -> DieselDeResult<Self> {
        Ok(OptionalVnDateTime(row))
    }
}

impl From<VnDateTime> for DateTime<FixedOffset> {
    fn from(v: VnDateTime) -> Self {
        v.0.with_timezone(&Ho_Chi_Minh).fixed_offset()
    }
}

impl From<OptionalVnDateTime> for Option<DateTime<FixedOffset>> {
    fn from(v: OptionalVnDateTime) -> Self {
        v.0.map(|dt| dt.with_timezone(&Ho_Chi_Minh).fixed_offset())
    }
}

rồi áp dụng như sau:

pub struct ActuatorSchedule {
    #[diesel(deserialize_as = VnDateTime)]
    pub starting_at: DateTime<FixedOffset>,
    #[diesel(deserialize_as = OptionalVnDateTime)]
    pub ending_at: Option<DateTime<FixedOffset>>,
}

Ở đây ta phải định nghĩa OptionalVnDateTime thay vì dùng lại Option<VnDateTime> là vì "orphan rule" của Rust không cho ta viết đoạn code như sau:

impl From<Option<VnDateTime>> for Option<DateTime<FixedOffset>> {
	...
}

Mặc dù VnDateTime là kiểu riêng của ta, nhưng Rust lại không coi Option<VnDateTime>> cũng là của ta, buồn nhỉ.

Như vậy mình đã chia sẻ một số kinh nghiệm để chuyển đổi một phần dự án Django / Python sang Rust. Kết quả của việc chuyển đổi là thế nào? Như với "Hạt Thóc" thì phiên bản ControlCenter Rust chạy nhanh, nhẹ hơn một cách rất ấn tượng: chỉ tiêu tốn bộ nhớ RAM có 13,6MiB, và dùng rất ít CPU (thời gian mà CPU phải thực thi lệnh xử lý chỉ là 12 phút trong tổng thời gian 5 giờ dịch vụ này chạy):

resource

Để so sánh, đây là hiệu năng của phiên bản ControlCenter cũ viết bằng Python khi chỉ điều khiển một cụm mười trang trại (do Python không thực sự chạy multithread trong nhiều trường hợp, nên để đảm bảo điều khiển đồng thời nhiều trang trại, mình cho ControlCenter chạy thành nhiều process, mỗi process điều khiển mười trang trại):

Python resource

Với kết quả này, có cần phải "oxy hóa" toàn bộ phần mềm không? Mình nghĩ là không, vì các thành phần kia (Collector, ControlView) không có nhiều áp lực và Python chạy vẫn rất ổn.