有的時候我們的一個 request 可能會需要執行好幾個工作來完成,例如之前舉例的,購買一個商品必須先驗證使用者、接著確認商品數量、建立訂單資訊、寫入 log,還可能有其他必須的步驟。如果資料庫跟 API 程式放在同一台機器還好,假如因為系統架構需求,資料庫與 API 程式在不同的機器、必須用網路溝通,這些步驟來來回回就會造成很成的等待時間,像這種情況,我們如果使用 Stored Procedure 就能大幅減少 API 需要等待的時間。
Stored Procedure (SP, 預存程序)是一組預先寫好的 SQL 腳本,可以一次執行多個指令,還可以在裡面做流程控制,而且這些指令可以重新被使用。使用 SP 有幾個主要的優點:
以前第一份工作的時候,有一個很強的主管常常會嘮叨的跟我說,叫我用 SP,現在回想起他的碎念還是覺得頗有趣 XD。
建立 SP 也需要執行 MySQL 的指令,可以從 MySQL 的命令列輸入,但是有連線的權限的話,當然筆者更推薦用 MySQL Workbench 編寫,因為 SP 通常會有很多行指令,用 MySQL 命令列個人覺得會有點卡。
建立 SP 的語法結構如下
DELIMITER //
CREATE PROCEDURE 預存程序名稱 (
	IN或OUT  參數1 資料型態1,
	IN或OUT  參數2 資料型態2
)
BEGIN
    各種 SQL 指令
END//
DELIMITER ;
第一個 DELIMITER 把預設的分隔符號從分號(;)改成兩個斜線(//),這個指令在使用 mysql 命令列建立 SP 時非常重要,因為沒有改變分隔符號,mysql 命令列看到分號就會停止我們的輸入並開始執行,如此一來就無法完整的建立 SP。而最後一個 DELIMITER 則是把分隔符號把兩個斜線再次改回分號。這裡的分隔符號其實可以自訂,最常見的是兩個斜線或兩個錢號($$)
接著就是幫我們的 SP 設定參數,參數可以是 IN 或 OUT,我們必須在呼叫 SP 的時候把參數一併帶入。OUT 參數在執行完 SP 後會被儲存再變數,我們可以透過 SLECT 指令將結果取出。
首先,我們來模擬上面的需求,額外新增商品與訂單的資料表
CREATE TABLE `product` (
  `product_id` int NOT NULL AUTO_INCREMENT,
  `product_name` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `price` decimal(10,0) NOT NULL,
  `stock` int NOT NULL,
  PRIMARY KEY (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE `user_order` (
  `order_id` int NOT NULL AUTO_INCREMENT,
  `user_id` int NOT NULL,
  `product_id` int NOT NULL,
  `order_status` varchar(45) NOT NULL,
  `create_datetime` datetime NOT NULL,
  `update_datetime` datetime DEFAULT NULL,
  PRIMARY KEY (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
塞入一些測試資料之後,建立一個 SP,接收使用者 ID、商品 ID 與 商品數量當作 IN 參數,外加一個輸出結果的 OUT 參數。如果使用者已經通過認證、而且商品數量足夠,就幫使用者建立一筆訂單資料、並寫入一筆 LOG,最後輸出 "OK" 的訊息。否則的話,救回傳錯誤代碼
CREATE DEFINER=`root`@`localhost` PROCEDURE `BuyProduct`(
	IN  in_user_id INT,
	IN  in_product_id INT,
	IN  in_buy_count INT,
	OUT out_result INT
)
BEGIN
    -- 取得使用者驗證狀態
	SET @user_verified := -1;
    
    SELECT verified 
    INTO @user_verified
    FROM `user` 
    WHERE user_id = in_user_id;
    
    IF @user_verified = -1 THEN
		SET out_result = -1; -- 無效的使用者 ID
	ELSEIF @user_verified = 0 THEN 
		SET out_result = -2; -- 使用者未通過驗證
	ELSEIF @user_verified = 1 THEN 
		-- 檢查庫存數量
        SET @stock := -1;
        
		SELECT stock 
        INTO @stock
        FROM product 
        WHERE product_id = in_product_id;
        
        IF @stock = -1 THEN
			SET out_result = -3; -- 無效的商品 ID
		ELSEIF @stock < in_buy_count THEN
			SET out_result = -4; -- 商品庫存不足
		ELSE
			-- 建立訂單
			INSERT INTO `user_order`
			(
				`user_id`,
				`product_id`,
				`order_status`,
				`create_datetime`
            )
			VALUES
			(
				in_user_id,
				in_product_id,
				'NEW ORDER',
				NOW()
            );
			-- 寫入 LOG
            INSERT INTO `user_log`
			(
				`user_id`,
				`action`,
				`action_datetime`
            )
			VALUES
			(
				in_user_id,
				'CREATE ORDER',
				NOW()
            );
            SET out_result = 1; -- OK
		END IF;
    END IF;
END
上面的 SP 裡有一些之前沒提過的用法:
- 變數
可以在 SP 中宣告並使用變數(本項目的延伸閱讀是內容農場翻譯簡體文章而來,但筆者覺得內容還不錯)- SELECT INTO
用 SELECT 指令把值存到暫存變數中,然後讓後續的指令使用- IF ELSE
在 SP 裡做流程控制
建立完 SP 後我們就能使用 CALL 預存程序名稱() 來呼叫這個 SP,比較要注意的是這邊帶給 SP 的參數的順序跟資料形態要對,還有最後一個參數是 "OUT" 參數,必須先宣告一個變數,然後讓 SP 把結果存到這個變數
SET @test := 0; -- 宣告儲存結果的變數
CALL BuyProduct('1', 1, 10, @test); -- 呼叫 SP
SELECT @test; -- 把結果取出來
執行上面的腳本就能看到我們的 SP 幫我們建立訂單並寫入 log 了!
因為上面的需求是執行一串指令然後回傳一個結果,所以筆者選擇使用 OUT 參數。如果要讓 SP 回傳多筆資料(就像一個資料表),則可以在 SP 結束之前,執行一個 SELECT ... FROM ... WHERE ... 的指令,這樣 SP 就會在執行結束後,把我們所需的資料都撈出來並回傳。
明天,我們將回到 .NET API,使用 Dapper 來操作 MySQL 裡的資料。