<-- Home |--rust

Rfe 010:工程师(粗)试图做一点没用的有用项目

没用还是有用

在学习一种程序设计语言的时候, 我们通常有两种张力很强、相互拉扯的焦虑, 如何用这门语言做点什么有用的东西, 一直在学用这门语言做没用的东西。 所以那个《XXX in Action》系列的书籍才会有卖得那么好…… 可是,那个书里依然都是一些有用的没用东西。 到底是有用的没用东西,还是没用的有用东西呢? 实际上,有用的东西多半没用,而没用的东西多半很有用。

有无相生,
难易相成。

还有一个就是有用的东西很难学,而没用的东西很容易学。 最出奇的事情,有些时候,难学的东西居然很没用,而容易学的东西却很有用。

所以,说一千道一万,我们还是先从没用的东西开始学吧。

没用的有用项目

Checklist

先来看一个例子,我想做一个非常有用的项目。 因为本人极其没有组织度, 做事情,做前面忘后面, 做后面忘前面。 后来我好好读过《Checklist Manifesto》这本书, 才知道原来有一个东西叫做 checklist, 专治我们这种毛病,一劳永逸……才怪。

于是,我就想做一个 checklist 的管理器, 来帮助我管理我的 checklist。 我觉得有这种毛病的肯定不是我一个人, 所以我想做一个大家都能够访问的 checklist 管理器, 来帮助大家管理 checklist。

想要说这个项目没用的人请不要说, 因为你们没有这个毛病,不能理解我们的痛苦。

需求分析

那就开始进行需求分析,需要的就是能够简单管理 checklist,能够随时随地访问 checklist,并且能够提供工具让我对照着checklist来完成一项工作。

核心的信息:checklist,checklist可以重复使用,比如我实际上已经编过一个checklist程序, 那是我在Windtunnel.cn写文章时所需要的步骤。

GUI界面的文章编写发布清单

# 写hugo文章: 用github管理hugo项目
更新本地库,git pull
新建一个分支来工作, git check -b branch_name
撰写文章,包括代码和图形, hugo new posts/artical_name.md
中途多次提交 commit 并 push 到服务器
把文章的 draft 标志设置为 false
commit 所有工作
把分支切换到主分支
合并工作分支
push 代码库,使 GitHub Action 渲染新文章

大概就是这样的,我以前的问题是,git push之后才发现,文章的 draft 标志没有设置为 false,导致文章没有被渲染出来。

同样的信息,如果用比较流行的的格式来表示可能就是:

 1{
 2  "id": 2,
 3  "title": "写hugo文章",
 4  "description": "用github管理hugo项目",
 5  "created_at": "2026-08-19T00:39:49.202487043Z",
 6  "items": [
 7    {
 8      "id": 3,
 9      "step": "更新本地库,git pull"
10    },
11    {
12      "id": 4,
13      "step": "新建一个分支来工作, git check -b branch_name"
14    },
15    {
16      "id": 5,
17      "step": "撰写文章,包括代码和图形, hugo new posts/artical_name.md"
18    },
19    {
20      "id": 6,
21      "step": "中途多次提交 commit 并 push 到服务器"
22    },
23    {
24      "id": 7,
25      "step": "把文章的 draft 标志设置为 false"
26    },
27    {
28      "id": 8,
29      "step": "commit 所有工作"
30    },
31    {
32      "id": 9,
33      "step": "把分支切换到主分支"
34    },
35    {
36      "id": 10,
37      "step": "合并工作分支"
38    },
39    {
40      "id": 11,
41      "step": "push 代码库,使 GitHub Action 渲染新文章"
42    }
43  ]
44}

核心的信息分析就是这个样子。

其次,需要确定信息如何收集、如何存储、如何访问、如何使用。 这个部分大概就是架构设计,可以用 Rust 来实现一个 RESTful API, 用 JavaScript 来实现一个网页。 当然应该也非常容易实现一个手机端,估计也就是稍微搞下、几百个Token的事情。

OpenAPI 文档:http://8.152.97.118:8091/api/docs, 随便实现一个单页面前端 Checklist Manager

实际上,有这个RESTful API,我们在这里就内嵌一个随机显示checklist。

从 API_BASE 拉取随机 checklist

随机显示一个 checklist,里面的每一项都是一个 step,用户可以点击 checkbox 来完成每一项。每一项的 checkbox 只能在前一项完成后才能解锁。

点击按钮后,会从 API_BASE/api/checklists/random 拉取随机清单,并在这里渲染 checkbox。

随机一个checklist的接口是 /api/checklists/random,返回值是一个 checklist 对象,里面的 items 是数组,每一项的字段是 step

架构和代码

架构

在实现一个信息化系统的初期, 我们总是会优先采用比较简单的技术来实现一系列松耦合的必要组件, 然后再把这些必不可少的组件组合起来, 形成一个功能上完整的系统。 至于这个系统的性能, 后面如果这个系统还能拿到钱的话, 再逐一替换就行。

对于我们的 checklist 管理器来说,我们可以把它拆分为三个组件:

  • 采用sqlite数据库来存储checklist数据
  • 提供一个RESTful API来访问这些数据
  • 提供一个网页(HTML/CSS/Javascript)来访问这些数据

大概上来说,就是这样几个层次,四个部分。

Server-Browser架构

在实际的实现中,前两个部分被整合在一起,形成一个完整的服务端程序,提供RESTful API。 后两个部分甚至被放在一个文件中(非常不好的做法),形成一个完整的前端网页,提供用户界面。

Server-Browser架构

代码构成

在实现时,按照3个工程来组织代码,代码都可以在Github上找到。

Checklist-core

首先是 checklist-core 的代码,主要是提供ChecklistItemChecklist两个结构体,以及它们的序列化和反序列化方法。通过serde库,我们可以很方便地将它们序列化为JSON格式,或者从JSON格式反序列化回来。当然,我们为了方便,也提供了从文本格式(类似Markdown的格式)来创建Checklist的功能。

  1/**
  2 * A single checklist step persisted as a row in the `checklist_items` table.
  3 */
  4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
  5pub struct ChecklistItem {
  6    #[serde(default)]
  7    pub id: i64,
  8    pub step: String,
  9    #[serde(skip_serializing, skip_deserializing, default = "default_ip")]
 10    pub created_by_ip: String,
 11}
 12
 13impl ChecklistItem {
 14    /// Creates a new checklist step with the caller-supplied creator IP for auditability.
 15    pub fn new(step: impl Into<String>, created_by_ip: impl Into<String>) -> Self {
 16        Self {
 17            id: 0,
 18            step: step.into(),
 19            created_by_ip: created_by_ip.into(),
 20        }
 21    }
 22}
 23
 24/**
 25 * A complete checklist, including metadata and all step entries.
 26 *
 27 * A checklist can be created from API payloads, imported from text, or serialized for
 28 * export and import flows.
 29 */
 30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
 31pub struct Checklist {
 32    #[serde(default)]
 33    pub id: i64,
 34    pub title: String,
 35    pub description: String,
 36    pub created_at: DateTime<Utc>,
 37    #[serde(default)]
 38    pub items: Vec<ChecklistItem>,
 39    #[serde(skip_serializing, skip_deserializing, default = "default_ip")]
 40    pub created_by_ip: String,
 41    #[serde(default, skip_serializing, skip_deserializing)]
 42    pub source_id: Option<i64>,
 43}
 44
 45impl Checklist {
 46    /// Creates a new checklist from a title, description, and raw item list.
 47    pub fn new(
 48        title: impl Into<String>,
 49        description: impl Into<String>,
 50        items: Vec<String>,
 51        created_by_ip: impl Into<String>,
 52    ) -> Self {
 53        let created_by_ip = created_by_ip.into();
 54        Self {
 55            id: 0,
 56            title: title.into(),
 57            description: description.into(),
 58            created_at: Utc::now(),
 59            items: items
 60                .into_iter()
 61                .map(|step| ChecklistItem::new(step, created_by_ip.clone()))
 62                .collect(),
 63            created_by_ip,
 64            source_id: None,
 65        }
 66    }
 67
 68    /// Serializes the checklist into pretty-printed JSON for export or API use.
 69    pub fn to_json(&self) -> Result<String> {
 70        Ok(serde_json::to_string_pretty(self)?)
 71    }
 72
 73    /// Parses a checklist from JSON produced by `to_json()` or another compatible payload.
 74    pub fn from_json(input: &str) -> Result<Self> {
 75        Ok(serde_json::from_str::<Checklist>(input)?)
 76    }
 77
 78    pub fn to_packed_string(&self) -> Result<String> {
 79        let json = self.to_json()?;
 80        Ok(BASE64.encode(json.as_bytes()))
 81    }
 82
 83    pub fn from_packed_string(input: &str) -> Result<Self> {
 84        let decoded = BASE64.decode(input)?;
 85        let json = String::from_utf8(decoded)?;
 86        Self::from_json(&json)
 87    }
 88
 89    pub fn from_text(input: &str) -> Result<Self> {
 90        let mut lines = input.lines();
 91        let header = lines
 92            .next()
 93            .ok_or_else(|| ChecklistError::InvalidInput("missing checklist header".to_string()))?
 94            .trim();
 95
 96        if !header.starts_with('#') {
 97            return Err(ChecklistError::InvalidInput(
 98                "header must start with '# '".to_string(),
 99            ));
100        }
101
102        let header = header.trim_start_matches('#').trim();
103        let (title, description) = match header.split_once(':') {
104            Some((title, description)) => (title.trim(), description.trim()),
105            None => (header.trim(), ""),
106        };
107
108        if title.is_empty() {
109            return Err(ChecklistError::InvalidInput(
110                "title must not be empty".to_string(),
111            ));
112        }
113
114        let items = lines
115            .filter_map(|line| {
116                let line = line.trim();
117                if line.is_empty() {
118                    None
119                } else {
120                    Some(line.to_string())
121                }
122            })
123            .collect::<Vec<_>>();
124
125        Ok(Self::new(title, description, items, "0.0.0.0"))
126    }
127
128    pub fn to_text(&self) -> String {
129        let description = if self.description.trim().is_empty() {
130            String::new()
131        } else {
132            format!(":{}", self.description)
133        };
134
135        let mut out = format!("# {}{}\n", self.title, description);
136        for item in &self.items {
137            out.push_str(&item.step);
138            out.push('\n');
139        }
140        out
141    }
142}

然后是一个非常Naive的Sqlite数据库访问层,提供了对checklist的增删改查操作。

  1/**
  2 * SQLite-backed persistence layer for checklists and their steps.
  3 *
  4 * All reads and writes are centralized here to keep the application logic testable and
  5 * consistent across API routes.
  6 */
  7pub struct ChecklistStore {
  8    conn: Connection,
  9    database_path: String,
 10}
 11
 12impl ChecklistStore {
 13    /// Opens or creates the SQLite database and initializes the required schema.
 14    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
 15        let database_path = path.as_ref().to_string_lossy().to_string();
 16        let conn = Connection::open(path)?;
 17        let store = Self {
 18            conn,
 19            database_path,
 20        };
 21        store.initialize()?;
 22        Ok(store)
 23    }
 24
 25    pub fn database_path(&self) -> &str {
 26        &self.database_path
 27    }
 28
 29    fn initialize(&self) -> Result<()> {
 30        self.conn.execute_batch(
 31            "
 32            CREATE TABLE IF NOT EXISTS checklists (
 33                id INTEGER PRIMARY KEY AUTOINCREMENT,
 34                title TEXT NOT NULL,
 35                description TEXT NOT NULL,
 36                created_at TEXT NOT NULL,
 37                created_by_ip TEXT NOT NULL,
 38                source_id INTEGER
 39            );
 40
 41            CREATE TABLE IF NOT EXISTS checklist_items (
 42                id INTEGER PRIMARY KEY AUTOINCREMENT,
 43                checklist_id INTEGER NOT NULL,
 44                step TEXT NOT NULL,
 45                created_by_ip TEXT NOT NULL,
 46                FOREIGN KEY(checklist_id) REFERENCES checklists(id)
 47            );
 48            ",
 49        )?;
 50        Ok(())
 51    }
 52
 53    pub fn create_checklist(&self, mut checklist: Checklist) -> Result<Checklist> {
 54        if checklist.title.trim().is_empty() {
 55            return Err(ChecklistError::InvalidInput(
 56                "title must not be empty".to_string(),
 57            ));
 58        }
 59
 60        let created_at = checklist.created_at.to_rfc3339();
 61        let created_by_ip = checklist.created_by_ip.clone();
 62
 63        self.conn.execute(
 64            "INSERT INTO checklists (title, description, created_at, created_by_ip, source_id) VALUES (?1, ?2, ?3, ?4, ?5)",
 65            params![
 66                checklist.title,
 67                checklist.description,
 68                created_at,
 69                created_by_ip,
 70                checklist.source_id,
 71            ],
 72        )?;
 73
 74        let checklist_id = self.conn.last_insert_rowid();
 75        checklist.id = checklist_id;
 76
 77        for item in &mut checklist.items {
 78            self.conn.execute(
 79                "INSERT INTO checklist_items (checklist_id, step, created_by_ip) VALUES (?1, ?2, ?3)",
 80                params![checklist_id, item.step, item.created_by_ip.clone()],
 81            )?;
 82            item.id = self.conn.last_insert_rowid();
 83        }
 84
 85        Ok(checklist)
 86    }
 87
 88    /// Returns the newest checklists ordered by creation time.
 89    pub fn list_checklists(&self) -> Result<Vec<Checklist>> {
 90        self.list_checklists_limited(None)
 91    }
 92
 93    /// Returns a capped subset of the newest checklists. The cap is intentionally enforced
 94    /// here so the API layer and the UI share a consistent upper bound.
 95    pub fn list_checklists_limited(&self, limit: Option<usize>) -> Result<Vec<Checklist>> {
 96        let limit = limit
 97            .unwrap_or(DEFAULT_RESULT_LIMIT)
 98            .min(DEFAULT_RESULT_LIMIT);
 99        let mut stmt = self.conn.prepare(
100            "SELECT id, title, description, created_at, created_by_ip, source_id FROM checklists ORDER BY created_at DESC LIMIT ?1",
101        )?;
102
103        let rows = stmt.query_map([limit as i64], |row| {
104            Ok((
105                row.get::<_, i64>(0)?,
106                row.get::<_, String>(1)?,
107                row.get::<_, String>(2)?,
108                row.get::<_, String>(3)?,
109                row.get::<_, String>(4)?,
110                row.get::<_, Option<i64>>(5)?,
111            ))
112        })?;
113
114        let mut checklists = Vec::new();
115        for row in rows {
116            let (id, title, description, created_at, created_by_ip, source_id) = row?;
117            let created_at = DateTime::parse_from_rfc3339(&created_at)
118                .map_err(|err| ChecklistError::InvalidInput(err.to_string()))?
119                .with_timezone(&Utc);
120
121            let items = self.load_items_for_checklist(id)?;
122            checklists.push(Checklist {
123                id,
124                title,
125                description,
126                created_at,
127                items,
128                created_by_ip,
129                source_id,
130            });
131        }
132
133        Ok(checklists)
134    }
135
136    pub fn get_checklist(&self, id: i64) -> Result<Option<Checklist>> {
137        let mut stmt = self.conn.prepare(
138            "SELECT title, description, created_at, created_by_ip, source_id FROM checklists WHERE id = ?1",
139        )?;
140
141        let row = stmt
142            .query_row([id], |row| {
143                Ok((
144                    row.get::<_, String>(0)?,
145                    row.get::<_, String>(1)?,
146                    row.get::<_, String>(2)?,
147                    row.get::<_, String>(3)?,
148                    row.get::<_, Option<i64>>(4)?,
149                ))
150            })
151            .optional()?;
152
153        let Some((title, description, created_at, created_by_ip, source_id)) = row else {
154            return Ok(None);
155        };
156
157        let created_at = DateTime::parse_from_rfc3339(&created_at)
158            .map_err(|err| ChecklistError::InvalidInput(err.to_string()))?
159            .with_timezone(&Utc);
160
161        let items = self.load_items_for_checklist(id)?;
162
163        Ok(Some(Checklist {
164            id,
165            title,
166            description,
167            created_at,
168            items,
169            created_by_ip,
170            source_id,
171        }))
172    }
173
174    pub fn create_new_version_from(
175        &self,
176        source_id: i64,
177        checklist: Checklist,
178    ) -> Result<Checklist> {
179        if self.get_checklist(source_id)?.is_none() {
180            return Err(ChecklistError::NotFound(source_id));
181        }
182
183        let mut version = checklist;
184        version.source_id = Some(source_id);
185        self.create_checklist(version)
186    }
187
188    /// Searches over checklist titles, descriptions, and item text.
189    pub fn search_by_term(&self, term: &str) -> Result<Vec<Checklist>> {
190        self.search_by_term_limited(term, None)
191    }
192
193    /// Searches with a safe server-side cap to keep result payloads predictable.
194    pub fn search_by_term_limited(
195        &self,
196        term: &str,
197        limit: Option<usize>,
198    ) -> Result<Vec<Checklist>> {
199        let limit = limit
200            .unwrap_or(DEFAULT_RESULT_LIMIT)
201            .min(DEFAULT_RESULT_LIMIT);
202        let needle = format!("%{}%", term.trim());
203        let mut stmt = self.conn.prepare(
204            "SELECT c.id, c.title, c.description, c.created_at, c.created_by_ip, c.source_id
205             FROM checklists c
206             LEFT JOIN checklist_items i ON i.checklist_id = c.id
207             WHERE c.title LIKE ?1 OR c.description LIKE ?1 OR i.step LIKE ?1
208             GROUP BY c.id
209             ORDER BY c.created_at DESC
210             LIMIT ?2",
211        )?;
212
213        let rows = stmt.query_map(params![&needle, limit as i64], |row| {
214            Ok((
215                row.get::<_, i64>(0)?,
216                row.get::<_, String>(1)?,
217                row.get::<_, String>(2)?,
218                row.get::<_, String>(3)?,
219                row.get::<_, String>(4)?,
220                row.get::<_, Option<i64>>(5)?,
221            ))
222        })?;
223
224        let mut results = Vec::new();
225        for row in rows {
226            let (id, title, description, created_at, created_by_ip, source_id) = row?;
227            let created_at = DateTime::parse_from_rfc3339(&created_at)
228                .map_err(|err| ChecklistError::InvalidInput(err.to_string()))?
229                .with_timezone(&Utc);
230            let items = self.load_items_for_checklist(id)?;
231            results.push(Checklist {
232                id,
233                title,
234                description,
235                created_at,
236                items,
237                created_by_ip,
238                source_id,
239            });
240        }
241
242        Ok(results)
243    }
244
245    pub fn random_checklist(&self) -> Result<Option<Checklist>> {
246        let row = self
247            .conn
248            .query_row(
249                "SELECT id FROM checklists ORDER BY RANDOM() LIMIT 1",
250                [],
251                |row| row.get::<_, i64>(0),
252            )
253            .optional()?;
254
255        match row {
256            Some(id) => self.get_checklist(id),
257            None => Ok(None),
258        }
259    }
260
261    pub fn delete_checklist(&self, id: i64) -> Result<()> {
262        self.conn
263            .execute("DELETE FROM checklist_items WHERE checklist_id = ?1", [id])?;
264        self.conn
265            .execute("DELETE FROM checklists WHERE id = ?1", [id])?;
266        Ok(())
267    }
268
269    fn load_items_for_checklist(&self, checklist_id: i64) -> Result<Vec<ChecklistItem>> {
270        let mut stmt = self.conn.prepare(
271            "SELECT id, step, created_by_ip FROM checklist_items WHERE checklist_id = ?1 ORDER BY id ASC",
272        )?;
273
274        let rows = stmt.query_map([checklist_id], |row| {
275            Ok((
276                row.get::<_, i64>(0)?,
277                row.get::<_, String>(1)?,
278                row.get::<_, String>(2)?,
279            ))
280        })?;
281
282        let mut items = Vec::new();
283        for row in rows {
284            let (id, step, created_by_ip) = row?;
285            items.push(ChecklistItem {
286                id,
287                step,
288                created_by_ip,
289            });
290        }
291
292        Ok(items)
293    }
294}

checklist-server

这个服务器的代码就过于简单,用axum来实现,主要的代码量都在描述RESTful API的OpenAPI文档上了。

 1fn app(state: AppState) -> Router {
 2    let cors = CorsLayer::new()
 3        .allow_origin(Any)
 4        .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
 5        .allow_headers(Any);
 6
 7    Router::new()
 8        .route(
 9            "/api/checklists",
10            get(list_checklists).post(create_checklist_handler),
11        )
12        .route("/api/checklists/search", get(search_checklists_handler))
13        .route("/api/checklists/random", get(random_checklist_handler))
14        .route(
15            "/api/checklists/{id}",
16            get(get_checklist_handler).delete(delete_checklist_handler),
17        )
18        .route(
19            "/api/checklists/{id}/export.json",
20            get(export_checklist_handler),
21        )
22        .route("/api/checklists/template", get(template_checklist_handler))
23        .route(
24            "/api/checklists/from-text",
25            post(create_checklist_from_text_handler),
26        )
27        .route("/api/checklists/import", post(import_checklist_handler))
28        .route("/api/checklists/pack", post(pack_checklist_handler))
29        .route("/api/checklists/unpack", post(unpack_checklist_handler))
30        .with_state(state)
31        .layer(cors)
32        .layer(TraceLayer::new_for_http())
33}

这个就是主要的API路由了,其他的就是一些辅助函数和错误处理。

主函数被标记为#[tokio::main],使用tokio运行时来运行异步的axum服务器。

 1#[tokio::main]
 2async fn main() {
 3    let cli = Cli::parse();
 4    let store = Arc::new(Mutex::new(
 5        ChecklistStore::open(&cli.database).expect("failed to open checklist database"),
 6    ));
 7
 8    let app = app(AppState {
 9        store: store.clone(),
10    });
11    let addr: SocketAddr = cli.bind.parse().expect("invalid bind address");
12
13    let docs = openapi_document();
14    let docs_for_json = docs.clone();
15    let router = app
16        .route("/api/docs", get(|| async { Html(swagger_ui_page()) }))
17        .route(
18            "/api-docs/openapi.json",
19            get(move || async move { Json(docs_for_json) }),
20        );
21
22    println!("Checklist server listening on http://{}", addr);
23    let listener = tokio::net::TcpListener::bind(addr)
24        .await
25        .expect("failed to bind to socket");
26    axum::serve(listener, router).await.expect("server failed");
27}

checklist-ui

这个网页的代码就更简单了,主要是一个HTML文件,里面包含了一些CSS和JavaScript代码,用来渲染checklist的界面。当然我们同样用一个tokio的服务器来提供这个网页。

 1#[derive(Clone)]
 2struct AppState {
 3    api_base: String,
 4}
 5
 6fn ui_html(api_base: &str) -> String {
 7    let safe_api_base = api_base.replace('\\', "\\\\").replace('"', "\\\"");
 8    include_str!("../static/index.html")
 9        .replace("__API_BASE__", &safe_api_base)
10}
11
12async fn index(State(state): State<AppState>) -> Html<String> {
13    Html(ui_html(&state.api_base))
14}
15
16#[tokio::main]
17async fn main() {
18    let cli = Cli::parse();
19    let state = AppState {
20        api_base: cli.api_base,
21    };
22
23    let app = Router::new()
24        .route("/", get(index))
25        .layer(TraceLayer::new_for_http())
26        .with_state(state);
27
28    let addr: SocketAddr = cli.bind.parse().expect("invalid bind address");
29    println!("Checklist UI listening on http://{}", addr);
30    let listener = tokio::net::TcpListener::bind(addr)
31        .await
32        .expect("failed to bind UI port");
33    axum::serve(listener, app).await.expect("UI server failed");
34}

我们的网页服务器跟checklist服务器是分开的,网页服务器只提供一个HTML文件,里面包含了JavaScript代码来访问checklist服务器的API。

运行与配置

这两个单可执行的文件就能够运行整个系统,在实际运行中,可以通过命令行参数来指定checklist服务器的API地址和网页服务器的绑定地址。

1#[derive(Parser, Debug)]
2#[command(author, version, about = "Checklist server")]
3struct Cli {
4    #[arg(long, default_value = "./checklists.db")]
5    database: PathBuf,
6    #[arg(long, default_value = "127.0.0.1:3000")]
7    bind: String,
8}
1#[derive(Parser, Debug)]
2#[command(author, version, about = "Checklist UI")]
3struct Cli {
4    #[arg(long, default_value = "http://127.0.0.1:3000")]
5    api_base: String,
6    #[arg(long, default_value = "0.0.0.0:8090")]
7    bind: String,
8}

从这里看,rust中的clap库是非常方便的,可以很容易地定义命令行参数和选项。 实际运行中,需要确保防火墙开放两个端口,一个是checklist服务器的端口,另一个是网页服务器的端口。

1checklist-server --database ./checklists.db --bind 0.0.0.0:8092
2checklist-ui --api-base http://xxxx.xxxx.xxxx.xxxx:8092 --bind 0.0.0.0:8090

实际上,前端和后端之间的联系是非常简单的,前端只需要知道后端的API地址,就可以通过HTTP请求来访问checklist的数据。具体的API接口和返回的数据格式,可以通过访问/api/docs来查看OpenAPI文档。

结论

Rust的生态系统已经非常成熟,能够支持我们快速地开发一个完整的Web应用程序。 什么性能、什么安全性、什么并发性,在信息系统的初期阶段都不是最重要的, 最重要的是能够快速地实现一个可用的系统,能够满足用户的需求。 说真的,那些说Rust不香的人,我也不知道他们在说什么。


文章标签

|-->rust |-->rfe |-->网络服务 |-->restful |-->web |-->api |-->http |-->json


GitHub