Code Style
Using rustfmt
Please make sure to ALWAYS use rustfmt
to format all files. We recommend using VSCode with the
rust-analyzer extension for writing code.
Below is the author's development environment for your reference:
-
VSCode Extensions
- CodeLLDB (Vadim Chugunov)
- crates (Seray Uzgur)
- Docker (Microsoft)
- GitHub Actions (Mathieu Dutour)
- rust-analyzer (The Rust Programming Language)
- YAML (Red Hat)
-
VSCode Settings
{ "crates.listPreReleases": true, "editor.formatOnSave": true, "editor.renderWhitespace": "all", "editor.roundedSelection": false, "editor.tabSize": 4, "files.eol": "\n", "rust-analyzer.inlayHints.chainingHints.enable": false, "rust-analyzer.inlayHints.closingBraceHints.enable": false, "rust-analyzer.inlayHints.parameterHints.enable": false, "rust-analyzer.inlayHints.typeHints.enable": false, "rust-analyzer.server.extraEnv": { "RUSTFLAGS": "-C instrument-coverage" } }
The use of the
-C instrument-coverage
environment variable is due to the author's need to generate coverage reports during testing. Adding this variable prevents recompilation triggered by saving and running tests. Below is the command for running tests:RUSTFLAGS="-C instrument-coverage" cargo test -p $PROJ --test integration_test -- --nocapture
MVC vs. Microservices
I prefer a bottom-up development approach. Using an architecture like MVC, which designs the
database as a lower-level generic interface, and implementing various functionalities called by the
API upper layer, aligns well with my personal style. This is the reason behind the creation of
models
and routes
.
However, when designing the entire Sylvia-IoT platform, I also aimed for modularity and chose a microservices-based approach (i.e., ABCD), strictly adhering to the principle of hierarchical dependencies.
Even with a microservices architecture, as described in the previous section
Directory Structure, when main.rs
references the required routes
, the entire
project can still be compiled into a single executable file and run on a single machine. This
design offers several deployment options, such as:
- Monolith: Running a single all-in-one executable on a single machine.
- Microservices cluster: Running each component independently on different machines, with each component setting up its own cluster.
- Monolith cluster: Running the all-in-one on multiple machines to form a clustered architecture.
Sylvia-IoT embodies the combination of both MVC and microservices design 😊.
File Content Arrangement
Each rs file is structured in the following way, with blank lines separating each section:
#![allow(unused)] fn main() { use rust_builtin_modules; use 3rd_party_modules; use sylvia_iot_modules; use crate_modules; pub struct PubStructEnums {} struct PrvStructEnums {} pub const PUB_CONSTANTS; const PRV_CONSTANTS; pub pub_static_vars; static prv_static_vars; impl PubStructEnums {} pub fn pub_funcs {} impl PrvStructEnums {} fn prv_funcs {} }
The general order is as follows:
- Using modules
- Structures
- Constants
- Variables
- Functions (including structure function implementations)
Within each section, pub
comes before private.
Model
The Model layer must provide a unified struct and trait interface. In the design philosophy of Sylvia-IoT, "plug-and-play" is a concept that is highly valued. Users should be able to choose appropriate implementations in different scenarios.
Database Design
When providing CRUD operations, the following order must be followed:
- count
- list
- get
- add
- upsert
- update
- del
Some points to note:
- count and list should provide consistent parameters so that the API and UI can call count and list in a consistent manner.
- Logger should not be used in the
model
. Errors should be returned to the upper layer to print the messages.- When multiple APIs call the same
model
, errors printed from the model cannot determine who made the call.
- When multiple APIs call the same
- When data cannot be retrieved, return
None
or an emptyVec
, not anError
. - Any database that can fulfill the "complex query" condition should be implementable using the same
trait interface.
- SQL, MongoDB meet this requirement.
- Redis cannot be designed in the form of a database.
Cache Design
- Any key-value store that can fulfill low-complexity read and write should be implementable
using the same trait interface.
- Redis, language-specific maps meet this requirement.
- SQL, MongoDB can also be implemented through querying a single condition. Using SQL or MongoDB for cache implementation is allowed when the system does not want to install too many different tools.
Routes (HTTP API)
In this section, the documentation and rules for implementing APIs are provided.
Verb Order
- POST
- GET /count
- GET /list
- GET
- PUT
- PATCH
- DELETE
Path
/[project]/api/v[version]/[function]
/[project]/api/v[version]/[function]/[op]
/[project]/api/v[version]/[function]/{id}
There is a potential ambiguity: [op]
and {id}
. The former represents a fixed action, while the
latter represents a variable object ID. When designing IDs, it is essential to avoid conflicts with
the names of actions.
When mounting routes using axum, the fixed
[op]
should be placed before the variable{id}
.
For example, let's consider the Broker's Device API:
- Device APIs
- POST /broker/api/v1/device Create device
- POST /broker/api/v1/device/bulk Bulk creating devices
- POST /broker/api/v1/device/bulk-delete Bulk deleting devices
- GET /broker/api/v1/device/count Device count
- GET /broker/api/v1/device/list Device list
- GET /broker/api/v1/device/{deviceId} Get device information
Here, you can see that the POST method handles creating single devices, bulk creating devices, and
bulk deleting devices. The bulk, bulk-delete, count, list are the previously
mentioned [op]
.
The design of device IDs should avoid conflicts with count and list.
Function Naming
The functions in api.rs
are named as follows:
fn [method]_[function]_[op]() {}
Continuing with the previous device API example, the functions would be named like this:
fn post_device() {}
fn post_device_bulk() {}
fn post_device_bulk_del() {}
fn get_device_count() {}
fn get_device_list() {}
fn get_device() {}
Request and Response Naming
Path variables, queries, and request bodies are defined in request.rs
, while response bodies are
defined in response.rs
. The naming convention is as follows (pay attention to capitalization):
struct [Id]Path {}
struct [Method][Function]Body {}
struct Get[Function]Query {}
For example:
struct DeviceIdPath {} // /device/{deviceId}
struct PostDeviceBody {}
struct GetDeviceListQuery {}