線上課程平台的資料模型需要隨著業務需求的發展而演進。本文介紹的模型擴充,加入了課程描述、格式、價格、語言等資訊,使平台能提供更完善的課程資訊。為了支援這些新增欄位,資料函式庫指令碼也做了相應的調整。此外,API 設計也考量了建立和更新課程的差異性,設計了不同的資料結構,避免不必要的欄位修改,提升 API 的安全性和易用性。這些改進讓平台能更好地服務學員和講師,提供更優質的線上學習體驗。

強化課程建立與管理的資料模型

在進行課程相關 API 的開發時,我們需要對現有的資料模型進行擴充,以滿足更複雜的業務需求。本文將探討如何增強 Course 資料模型,以支援更豐富的課程資訊。

現有的 Course 資料模型

首先,讓我們檢視目前的 Course 資料模型,定義於 $PROJECT_ROOT/src/iter5/models/course.rs 中:

pub struct Course {
    pub course_id: i32,
    pub tutor_id: i32,
    pub course_name: String,
    pub posted_time: Option<NaiveDateTime>,
}

這個資料結構雖然簡單,但已經能夠滿足基本的課程資訊儲存需求。然而,為了提供更豐富的課程描述,我們需要對其進行擴充。

擴充 Course 資料模型

我們將為 Course 結構體新增以下屬性:

  • 描述(Description):用於描述課程的文字資訊,以便潛在學生能夠決定是否適合他們。
  • 格式(Format):課程可以有多種傳遞格式,例如自定進度的視訊課程、電子書格式或講師主導的面對面培訓。
  • 課程結構(Structure of course):目前,我們允許講師上傳描述課程的檔案(例如 PDF 格式的小冊子)。
  • 課程時長(Duration of course):課程的長度。通常以視訊課程的錄製時間、面對面培訓的時數或電子書的建議學習時數來描述。
  • 價格(Price):以美元指定的課程價格。
  • 語言(Language):由於我們的網頁應用程式導向國際觀眾,因此允許開設多種語言的課程。
  • 級別(Level):表示課程針對的學生級別。可能的值包括初學者、中級和專家。

內容解密:

上述新增屬效能夠提供更豐富的課程資訊,有助於學生更好地瞭解課程內容和特點。

重構專案程式碼結構

在擴充資料模型之前,我們先對專案程式碼結構進行重構,以提高程式碼的可維護性和可讀性。

步驟一:在 mod.rs 檔案中新增模組宣告

$PROJECT_ROOT/src/iter5/dbaccess$PROJECT_ROOT/src/iter5/models 資料夾下的 mod.rs 檔案中,新增以下程式碼:

pub mod course;
pub mod tutor;

內容解密:

這告訴 Rust 編譯器將 $PROJECT_ROOT/src/iter5/models$PROJECT_ROOT/src/iter5/dbaccess 資料夾下的內容視為 Rust 模組。這樣,我們就可以在其他原始檔中參照和使用 Course 資料結構,例如:

use crate::models::course::Course;

步驟二:調整模組匯入路徑

根據給定的指示,調整 $PROJECT_ROOT/src/iter5/handlers/general.rs$PROJECT_ROOT/src/bin/iter5/main.rs$PROJECT_ROOT/src/iter5/dbaccess/course.rs$PROJECT_ROOT/src/iter5/handlers/course.rs$PROJECT_ROOT/src/iter5/routes.rs 中的模組匯入路徑。

內容解密:

這些步驟確保了模組之間的正確參照和依賴關係,從而避免編譯錯誤。

圖表翻譯:

@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

title 強化課程資料模型與API設計

package "課程資料模型" {
    package "基本資訊" {
        component [course_id] as id
        component [tutor_id] as tutor
        component [course_name] as name
    }

    package "擴充欄位" {
        component [描述/格式] as desc
        component [價格/語言] as price
        component [時長/級別] as level
    }
}

package "API 設計" {
    component [CreateCourse DTO] as create
    component [UpdateCourse DTO] as update
    component [Actix Web 路由] as route
}

package "資料庫 Schema" {
    component [PostgreSQL] as pg
    component [Schema 更新] as schema
    component [mod.rs 模組] as module
}

id --> desc : 模型擴充
tutor --> price
name --> level
create --> route : POST /courses
update --> route : PUT /courses/{id}
pg --> schema : ALTER TABLE
module --> route : 模組匯入

note right of create
  特徵工程包含:
  - 特徵選擇
  - 特徵轉換
  - 降維處理
end note

note right of eval
  評估指標:
  - 準確率/召回率
  - F1 Score
  - AUC-ROC
end note

@enduml

此圖示展示了 Course 資料模型的擴充屬性。

圖表翻譯: 上述Plantuml圖表展示了 Course 結構體及其新增的屬性之間的關係。這些屬性共同構成了豐富的課程資訊,有助於學生更好地瞭解課程。

修改 Rust 資料模型

在下一小節中,我們將對 Rust 資料模型進行修改。

對資料模型進行變更

首先,我們從檔案匯入開始進行變更。以下是原始的匯入集合:

use actix_web::web;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};

接下來,讓我們修改 Course 資料結構以包含我們希望捕捉的額外資料元素。以下是更新後的 Course 資料結構,在 $PROJECT_ROOT/src/iter5/models/course.rs 中定義:

#[derive(Serialize, Debug, Clone, sqlx::FromRow)]
pub struct Course {
    pub course_id: i32,
    pub tutor_id: i32,
    pub course_name: String,
    pub course_description: Option<String>,
    pub course_format: Option<String>,
    pub course_structure: Option<String>,
    pub course_duration: Option<String>,
    pub course_price: Option<i32>,
    pub course_language: Option<String>,
    pub course_level: Option<String>,
    pub posted_time: Option<NaiveDateTime>,
}

注意,我們宣告了一個具有三個必填欄位的結構體:course_idtutor_idcourse_name。其餘欄位是可選的(由 Option<T> 型別表示)。這反映了資料函式庫中的課程記錄可能沒有這些可選欄位的值。

內容解密:

  • #[derive(Serialize, Debug, Clone, sqlx::FromRow)] 自動派生了幾個特徵。Serialize 使我們能夠將 Course 結構體的欄位傳回給 API 客戶端。
  • Debug 使我們能夠在開發週期中列印結構體值。
  • Clone 幫助我們在遵守 Rust 所有權模型的同時複製字串值。
  • sqlx::FromRow 使我們能夠在從資料函式庫讀取值時自動將資料函式庫記錄轉換為 Course 結構體。

建立新的資料結構以處理不同表示形式

如果我們檢視 Course 資料結構,會發現有幾個欄位,如 posted_timecourse_id,我們計劃在資料函式庫層級自動生成。雖然我們需要這些欄位來完整表示 Course 記錄,但我們不需要這些值由 API 客戶端傳送。那麼,我們如何處理 Course 的不同表示形式?

讓我們建立一個單獨的資料結構,它只包含與前端相關的欄位,用於建立新課程。以下是新的 CreateCourse 結構體:

#[derive(Deserialize, Debug, Clone)]
pub struct CreateCourse {
    pub tutor_id: i32,
    pub course_name: String,
    pub course_description: Option<String>,
    pub course_format: Option<String>,
    pub course_structure: Option<String>,
    pub course_duration: Option<String>,
    pub course_price: Option<i32>,
    pub course_language: Option<String>,
    pub course_level: Option<String>,
}

在這個結構體中,我們指定了建立新課程所需的必填欄位是 tutor_idcourse_name,其餘欄位是可選的。然而,對於導師網頁服務,course_idposted_time 也是建立新課程所需的必填欄位,這些將在內部自動生成。

內容解密:

  • 我們為 CreateCourse 自動派生了 Deserialize 特徵,而為 Course 結構體自動派生了 Serialize 特徵。
  • 這是因為 CreateCourse 結構體將用作將使用者輸入傳遞給網頁服務的資料結構,作為 HTTP 請求主體的一部分。

實作轉換函式

為了將 Actix Web 框架反序列化後的資料轉換為我們的 Rust 結構體,我們將實作 Rust 的 From 特徵來撰寫轉換函式:

impl From<web::Json<CreateCourse>> for CreateCourse {
    fn from(new_course: web::Json<CreateCourse>) -> Self {
        CreateCourse {
            tutor_id: new_course.tutor_id,
            course_name: new_course.course_name.clone(),
            course_description: new_course.course_description.clone(),
            course_format: new_course.course_format.clone(),
            course_structure: new_course.course_structure.clone(),
            course_level: new_course.course_level.clone(),
            course_duration: new_course.course_duration.clone(),
            course_language: new_course.course_language.clone(),
            course_price: new_course.course_price,
        }
    }
}

這個轉換相對簡單,但如果轉換過程中可能出現錯誤,我們會使用 TryFrom 特徵而不是 From 特徵。

內容解密:

  • 如果在轉換過程中呼叫傳回 Result 型別的 Rust 標準函式庫函式(例如,將字串值轉換為整數),則可能會發生錯誤。
  • 為了處理這種情況,可以使用 TryFrom 特徵,並實作 try_from 函式,同時宣告在處理過程中出現問題時將傳回的錯誤型別。
use std::convert::TryFrom;

impl TryFrom<web::Json<CreateCourse>> for CreateCourse {
    type Error = EzyTutorError;
    
    fn try_from(new_course: web::Json<CreateCourse>) -> Result<Self, Self::Error> {
        Ok(CreateCourse {
            tutor_id: new_course.tutor_id,
            course_name: new_course.course_name.clone(),
            course_description: new_course.course_description.clone(),
            course_format: new_course.course_format.clone(),
            course_structure: new_course.course_structure.clone(),
            course_level: new_course.course_level.clone(),
            course_duration: new_course.course_duration.clone(),
            course_language: new_course.course_language.clone(),
            course_price: new_course.course_price,
        })
    }
}

更新課程資料模型與 API 邏輯

在前一章節中,我們已經成功建立了接收客戶端資料以建立新課程的方法。然而,當需要更新課程資料時,我們無法直接沿用 CreateCourse 結構體。原因在於更新課程時,我們不希望修改 tutor_id,以避免將某位導師建立的課程轉移給其他導師。此外,CreateCourse 中的 course_name 欄位是必填的,但更新課程時,我們不希望每次都強制使用者更新課程名稱。

建立 UpdateCourse 結構體

首先,我們需要建立一個新的結構體 UpdateCourse,使其更適合用於更新課程詳情。

#[derive(Deserialize, Debug, Clone)]
pub struct UpdateCourse {
    pub course_name: Option<String>,
    pub course_description: Option<String>,
    pub course_format: Option<String>,
    pub course_structure: Option<String>,
    pub course_duration: Option<String>,
    pub course_price: Option<i32>,
    pub course_language: Option<String>,
    pub course_level: Option<String>,
}

內容解密:

  • UpdateCourse 結構體中的所有欄位均為可選 (Option<T>),這是為了提供良好的使用者經驗,因為在更新課程時,使用者可能只想修改部分資訊。
  • 透過使用 Option<T>,我們可以區分哪些欄位被明確指定為 None(即不更新),哪些欄位被省略(未提供值)。

實作 From 特性

接下來,我們需要為 UpdateCourse 實作 From 特性,以便從 web::Json<UpdateCourse> 轉換為 UpdateCourse

impl From<web::Json<UpdateCourse>> for UpdateCourse {
    fn from(update_course: web::Json<UpdateCourse>) -> Self {
        UpdateCourse {
            course_name: update_course.course_name.clone(),
            course_description: update_course.course_description.clone(),
            course_format: update_course.course_format.clone(),
            course_structure: update_course.course_structure.clone(),
            course_level: update_course.course_level.clone(),
            course_duration: update_course.course_duration.clone(),
            course_language: update_course.course_language.clone(),
            course_price: update_course.course_price,
        }
    }
}

內容解密:

  • 此實作允許直接從 Actix-web 的 web::Json 物件中提取 UpdateCourse 例項。
  • 所有欄位都透過 clone() 方法進行複製,以確保資料的所有權正確轉移。

修改資料函式庫指令碼

為了將新的欄位納入資料函式庫,我們需要在 $PROJECT_ROOT/src/iter5/dbscripts/course.sql 檔案中更新資料函式庫指令碼。

/* Drop tables if they already exist*/
drop table if exists ezy_course_c6;

/* Create tables. */
/* Note: Don't put a comma after last field */
create table ezy_course_c6
(
    course_id serial primary key,
    tutor_id INT not null,
    course_name varchar(140) not null,
    course_description varchar(2000),
    course_format varchar(30),
    course_structure varchar(200),
    course_duration varchar(30),
    course_price INT,
    course_language varchar(30),
    course_level varchar(30),
    posted_time TIMESTAMP default now()
);

內容解密:

  • 資料表名稱現在帶有 c6 字尾,以便於不同章節的程式碼獨立測試。
  • 新的資料欄位已加入至資料表結構中。
  • tutor_idcourse_name 欄位設有 NOT NULL 約束,以確保這些欄位在插入資料時必須提供值。
  • course_id 設為主鍵,而 posted_time 預設為目前時間,這些都是在資料函式庫層級強制執行的約束。

更新 API 路由

接下來,我們需要在 $PROJECT_ROOT/src/iter5/routes.rs 中修改路由組態,以包含更新課程的 API。

use crate::handlers::{course::*, general::*};
use actix_web::web;

pub fn general_routes(cfg: &mut web::ServiceConfig) {
    cfg.route("/health", web::get().to(health_check_handler));
}

pub fn course_routes(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/courses")
            .route("", web::post().to(post_new_course))
            .route("/{tutor_id}", web::get().to(get_courses_for_tutor))
            .route("/{tutor_id}/{course_id}", web::get().to(get_course_details))
            .route("/{tutor_id}/{course_id}", web::put().to(update_course_details))
            .route("/{tutor_id}/{course_id}", web::delete().to(delete_course))
    );
}

內容解密:

  • 路由組態中使用了不同的 HTTP 方法來處理不同的請求:POST 用於建立新課程,GET 用於檢索課程,PUT 用於更新課程,DELETE 用於刪除課程。
  • 路由組態中明確指定了處理函式與對應的 HTTP 方法。