Rust 學的一頭霧水?錯,是太難了根本學不會,直接上實踐就完事了。就用學習一個框架最經典的實戰項目 TodoMVC,我們實現一個 rust+sqlite 做後端、react 做前端的跨平台桌面端 app
創建 Tauri 項目#
雖然根據官方文檔新建一個項目很簡單。
不過我用 pnpm 做包管理,pnpm 創建項目運行如下
pnpm create tauri-app
我們選擇使用從create-vite
然後使用 react-ts 模板
然後等待 cli 安裝完依賴,用 VSCode 打開項目,這裡建議你安裝rust-analyzer
不過我估計學習 rust 應該早都推薦安裝了,然後我們的項目目錄就如下
存放前端項目內容的 src 和 rust 後端的 src-tauri,對於 web 界面的開發就很正常開發 react 一樣,但是對於和編寫 rust 後端就不同於 electron 了畢竟 rust 和 nodejs 完全不一樣
構建頁面#
首先我們直接使用 TodoMVC 這個項目提供的 css,安裝todomvc-app-css
pnpm add todomvc-app-css
然後在入口文件引入,並把原先引入的樣式文件刪掉
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
+ import 'todomvc-app-css/index.css'
- import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
然後新建 components 目錄並創建 TodoItem 組件
const TodoItem = () => {
return (
<li>
<div className="view">
<input type="checkbox" className="toggle" autoFocus />
<label>一些待辦項目</label>
<button className="destroy"></button>
</div>
</li>
)
}
export default TodoItem
以及 TodoList 組件
import TodoItem from './TodoItem'
const TodoList = () => {
return (
<>
<header className="header">
<h1>待辦事項</h1>
<input type="text" className="new-todo" placeholder="需要做什麼?" />
</header>
<section className="main">
<input type="checkbox" className="toggle-all" />
<label htmlFor="togle-all"></label>
<ul className="todo-list">
<TodoItem />
</ul>
</section>
<footer className="footer">
<span className="todo-count">
<strong>1</strong> 項剩餘
</span>
<ul className="filters">
<li>
<a>全部</a>
</li>
<li>
<a>活動中</a>
</li>
<li>
<a>已完成</a>
</li>
</ul>
</footer>
</>
)
}
export default TodoList
然後在 App.tsx 中把原先模板中的代碼刪除然後引入 TodoList 並展示出來。
import TodoList from './component/TodoList'
function App() {
return (
<div className="todoapp">
<TodoList />
</div>
)
}
export default App
啟動 Tauri#
然後啟動 tauri,就可以看到效果了
pnpm tauri dev
不過這部分僅僅只是顯示簡單的 html 結構以及相應的 css 樣式沒有任何功能
開發後端#
實現 web 界面功能先放到一邊,先來考慮下 rust 後端操作,先了解下 tauri 怎麼通信的
根據官方文檔我們可以通過 TauriAPI 包或者設置tauri.conf.json > build > withGlobalTauri
為 true 來將 invoke 掛載到 window.TAURI 對象上,比較建議開啟withGlobalTauri
讓一會的調試更簡單,雖然 tauri 官方有 test 但是我覺得直接在控制台測試更簡單
然後我們就可以使用 invoke 調用 rust 後端提供的方法了
以下的操作均為
src-tauri/
目錄下
使用 sqlite#
首先添加 rusqlite 依賴來獲得操作 sqlite 的能力
[dependencies]
# ...
rusqlite = { version = "0.27.0", features = ["bundled"] }
對 sqlite 資料庫的操作#
參考 rusqlite的用法,我們創建一個方法來創建資料庫連接。
fn connect() -> Result<()>{
let db_path = "db.sqlite";
let db = Connection::open(&db_path)?;
println!("{}", db.is_autocommit());
Ok(())
}
然後我們可以實現更多的方法來對資料庫增刪改查,但寫太多方法每次都要創建資料庫連接然後斷開,比較麻煩,於是我們可以實現一個結構體TodoApp
來封裝常用的方法
設計 TodoApp 類#
設計表結構#
首先我們設計一下資料庫表結構
Todo 表比較簡單的結構,建表語句:
CREATE TABLE IF NOT EXISTS Todo (
id varchar(64) PRIMARY KEY,
label text NOT NULL,
done numeric DEFAULT 0,
is_delete numeric DEFAULT 0
)
todo 模塊#
然後我們新建一個todo.rs
模塊,同時建立一個 Todo 結構體作為資料庫行的類型供未來使用,這裡都用 pub 因為我們可能在 main 中使用的時候訪問這些屬性
pub struct Todo {
pub id: String,
pub label: String,
pub done: bool,
pub is_delete: bool,
}
以及 TodoApp 結構體,不過暫時未實現內部的方法
pub struct TodoApp {
pub conn: Connection,
}
impl TodoApp {
//待實現
}
然後就是抽象 CURD 成幾個成員方法,由於 rust 不存在 new 這個關鍵詞,我們構造一個類對象一般約定存在一個pub fn new()
來對相應的類進行構造,其實所謂的構造就是返回一個存在 impl 的結構體。
於是添加以下實現
impl TodoApp{
pub fn new()->Result<TodoApp>{
let db_path = "db.sqlite";
let conn = Connection::open(db_path)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS Todo (
id varchar(64) PRIMARY KEY,
label text NOT NULL,
done numeric DEFAULT 0,
is_delete numeric DEFAULT 0
)",
[],
)?;
Ok(TodoApp { conn })
}
}
使用Result
枚舉類型是因為Connection::open
返回的也是Result
,我們希望使用?
通過錯誤傳播來簡化錯誤流程處理(雖然這個方法也沒有 Err,所以就只是為了簡化流程罷了)
然後我們就可以通過TodoApp::new().unwrap()
來構造一個 TodoApp 類了,使用 unwrap () 拆解枚舉類型中的 Ok 即我們返回的 TodoApp
實現 TodoApp 各種方法#
既然已經能夠構造類了,那麼我們希望能對 sqlite 進行 CURD 等操作,當然需要相應的方法,如 get_todos
、get_todo(id)
、new_todo(todo)
、update_todo(todo)
,沒有刪除的方法因為設計表就已經設計了 is_delete 字段,決定我們的刪除還是軟刪除,硬刪除暫時不 (lān de) 實現
查詢所有的 todo#
使用到 Connection.prepare()
方法,這個方法返回的Statement
的幾個方法query_map
、execute
等,可以接收參數然後將參數傳遞調用prepare()
時的語句中進行查詢並返回。
這裡我們使用query_map
方法來得到一個迭代器,通過遍歷迭代器我們獲得了Vec<Todo>
就是 Todo 對象的數組然後將其通過Result
枚舉包裝然後返回
注意帶有泛型但泛型不受參數控制的方法如 line 6 這個row.get
方法傳入泛型參數是以row.get::<I, T>()
方式調用的。
因為 sqlite 中沒有 boolean 類型所以我們使用 numeric 通過 1 或 0 來標識 true 或 false,使用對於這兩個字段都需要記得處理一下
impl TodoApp {
pub fn get_todos(&self) -> Result<Vec<Todo>> {
let mut stmt = self.conn.prepare("SELECT * FROM Todo").unwrap();
let todos_iter = stmt.query_map([], |row| {
let done = row.get::<usize, i32>(2).unwrap() == 1;
let is_delete = row.get::<usize, i32>(3).unwrap() == 1;
Ok(Todo {
id: row.get(0)?,
label: row.get(1)?,
done,
is_delete,
})
})?;
let mut todos: Vec<Todo> = Vec::new();
for todo in todos_iter {
todos.push(todo?);
}
Ok(todos)
}
}
於是我們可以獲得 Sqlite 中的數據了,但是仍然需要提供 command 來供前端調用,我們回到 main.ts,先引入模塊並導入 Todo 和 TodoApp
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
mod todo;
use todo::{Todo, TodoApp};
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
get_todos,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[tauri::command]
fn get_todos() -> Vec<Todo> {
todo!()
}
封裝 tauri 狀態
我們需要使用 rust 標準庫中的 Mutex 互斥鎖以及 tauri 的 state 來讓我們封裝的 TodoApp 對象能夠跨進程調用,首先新建一個AppState
結構體做狀態管理。
然後通過tauri::Builder
的manage
方法來管理這個狀態。然後我們就可以封裝command
來使用這個對象的方法來獲取數據了。
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
mod todo;
use todo::{Todo, TodoApp};
struct AppState {
app: Mutex<TodoApp>,
}
fn main() {
let app = TodoApp::new().unwrap();
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
get_todos,
])
.manage(AppState {
app: Mutex::from(app),
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[tauri::command]
fn get_todos() -> Vec<Todo> {
let app = state.app.lock().unwrap();
let todos = app.get_todos().unwrap();
todos
}
返回數據序列化
寫好 command 之後發現註解這裡報錯了。
這裡是因為我們返回的Vec<Todo>
不是可以序列化的類型不能通過指令返回到前端,回到 todo.rs,我們增加註解給結構體增加序列化的功能。
use serde::{Serialize};
#[derive(Serialize)]
pub struct Todo {
pub id: String,
pub label: String,
pub done: bool,
pub is_delete: bool,
}
然後我們在界面上右鍵檢查打開控制台然後輸入await __TAURI__.invoke("get_todos")
應該就能看到返回的空數組了
invoke 參數反序列化
其實需要序列化和反序列化的原因就和前後端分離的 web 應用一樣,在傳輸層使用的是 json 格式,但應用需要真正的對象,所以需要通過註解給對象添加 Serialize 和 Deserialize 接口
同時 invoke 方法也是可以接受第二個參數作為對 commond 調用的參數的,但是參數也需要具備從 json 格式反序列化數據的能力,於是增加註解
use serde::{Deserialize};
use serde::{Serialize, Deserialize};
#[derive(Serialize)]
#[derive(Serialize, Deserialize)]
pub struct Todo {
pub id: String,
pub label: String,
pub done: bool,
pub is_delete: bool,
}
完善 CURD#
除了使用Connection::prepare
返回的Statement
中的方法,我們也可以從Connection
直接execute
SQL 語句,比如這個新增 todo,從 invoke 中獲取 todo 參數並反序列化成Todo
對象,然後結構獲得 id 和 label 然後傳遞給 SQL 語句的參數完成 INSERT
pub fn new_todo(&self, todo: Todo) -> bool {
let Todo { id, label, .. } = todo;
match self
.conn
.execute("INSERT INTO Todo (id, label) VALUES (?, ?)", [id, label])
{
Ok(insert) => {
println!("{} 行插入", insert);
true
}
Err(err) => {
println!("一些錯誤: {}", err);
false
}
}
}
同理還有update_todo
、get_todo
,這裡就不多列代碼了,就給一個函數簽名吧,這裡返回值願意通過 Result 封裝或者不封裝其實應該問題都不大,看個人喜好了。
pub fn update_todo(&self, todo: Todo) -> bool {
// 更多代碼
}
pub fn get_todo(&self, id: String) -> Result<Todo> {
// 也有更多代碼
}
同理也需要增加相應的指令
#[tauri::command]
fn new_todo(todo: Todo) -> bool {
let app = TodoApp::new().unwrap();
let result = app.new_todo(todo);
app.conn.close();
result
}
#[tauri::command]
fn update_todo(todo: Todo) -> bool {
//待實現
}
#[tauri::command]
fn toggle_done(id: String) -> bool {
//待實現
}
以及別忘了在 generate_handler 中增加
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
get_todos,
new_todo,
toggle_done,
update_todo
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
至此我們就基本完成了 TodoMVC 的後端,接下來在下篇中使用 react + jotai + 一些包 來完成這個應用的前端以及與 rust 後端的通信這部分就很水了,基本就是基礎的 react