iT邦幫忙

2023 iThome 鐵人賽

DAY 24
0

前一篇完成了後端的websocket,我們接下來來進行前端的部分。

讓 Svelte 接上 websocket

直接到我們遊戲的頁面,把之前寫的ws_client import進來:

<!-- app/src/routes/tic_tac_toe/+page.svelte -->
<script>
  import { wsClient } from "../../api/ws_client";

  wsClient().onmessage = (e) => {
    console.log(e.data);
  };
  // ...略
</script>

開啟瀏覽器開發工具看看:

看看Browser的Console是否印出websocket訊息

怎麼訊息好像怪怪的有點亂:

  • MessageEvent開頭的:是我一開始console.log(e)的結果,後來我才加上e.data,所以前端程式hot reload時,之前開的websocket連線物件還在,並沒有被關閉所造成。
  • 圓圓的2在前面:就是表示重覆2個一模模一樣樣的訊息,我在測試的時候切到別的頁面又切回來時,就又增加一個連線,wsClient是在頁面開啟始執行一次連線,一直進來就一直增加連線,並沒有關掉舊的。

我們先解決這個問題,到ws_client裡,調整一下寫法:

@@ app/src/api/ws_client.ts @@
+let ws : WebSocket;

 export const wsClient = (): WebSocket => {
   const url = `${import.meta.env.VITE_WS_BASE_URL}/echo`;

+  // 連線物件不存在,或連線狀態不是OPEN,就重新建立一個連線物件
+  if (!ws || ws.readyState !== 1) {
+    ws = new WebSocket(url);
+  }
-  const ws: WebSocket = new WebSocket(url);

這邊把變數定義在 wsClient 函數之外,並且在創建前先檢查是否存在,或是否是連線狀態,至於webSocket好像只能手動重建連線物件,沒有內建重新連線的機制,所以我們這裡就直接再new一個WebSocket並指派。設定好到前端終端裡看看,試著把畫面切來切去看看:

測試單一WebSocket實例下的訊息是否正確顯示

看起來訊息都是只有出現一次,接受訊息的部分看起來OK了。

前端除錯,前端之前有一些洞先補起來

進行到這邊,如果執行pnpm build會出現錯誤訊息:

nodejs找不到websocket物件

因為node.js裡好像沒WebSocket可以用,所以直接報錯找不到WebSocket物件,其實這邊是new WebSocket 出錯,interface是正常的,我們加一下判斷當前環境,在wsCleint函數最前面加上:

@@ app/src/api/ws_client.ts @@
export const wsClient = (): WebSocket => {
+  if (typeof window === 'undefined') { return {} as WebSocket; }
   const url = `${import.meta.env.VITE_WS_BASE_URL}/echo`;

windows是在瀏覽器環境才會出現,所以先判斷當前環境如果不是在瀏覽器就給一個Null Instance Object。

一樣是這個window,我們之前在api也有寫,當時的寫法不好,所以在hot reload時常常會有問題,之前有跟著跑的朋友應該會發現前端畫面有時候熱重載後要多點兩下才會出來,就是因為後端這個錯誤造成的影響。

後端找不到windows的警示訊息

我們這裡也順便一起修一修:

@@ app/src/api/index.ts @@
+export const api: Api = (typeof window !== 'undefined' && window?.__TAURI_IPC__) ? tauriApi : httpApi;
-export const api: Api = window?.__TAURI_IPC__ ? tauriApi : httpApi;

還有一個問題:

找不到tic_tac_toe路由

這個訊息說找不到路由sverdle/tic_tac_toesvedle是我們當初用sveltekit模板產生專案時候帶出來的一個小遊戲,而我們的tic_tac_toe不該在它底下,原來是在第11篇加路由的時候,連結沒設好,前面沒加/的話是相對路徑,svelte在編譯時就無法正確帶入,修改一下:

@@ app/src/routes/Header.svelte @@
+    <a href="/tic_tac_toe">井字遊戲</a>
-    <a href="tic_tac_toe">井字遊戲</a>

連結有加/開頭就是對絕路徑,沒有的話就是相對路徑,所以是看當前在哪裡就在哪裡往後加,調整完後執行結果:

pnpm build 正確執行結束

這次修完bug並成功建置,看到提示訊息有寫到,可以使用run preview來預覽production版本,比我們一開始用static http server簡單多了。

~/demo-app/app$ pnpm run preview

執行完終端畫面如下:

執行pnpm run preview的終端畫面結果

可以看到服務跑在不同的port號,使用瀏覽器開啟實測一下:

使用瀏覽器開啟測試websocket是否正常執行

看起來沒有問題,再接著往下處理顯示的部分。

幫 Svelte 加上 Notification

首先訊息的部分,我希望用一個彈出式的訊息框呈現,但又不想要遮擋用戶畫面,可以google,這裡的關鍵字不要下alert,找一下svelte有什麼notificationnotify的套件,就可以找到一些不同的比較一下,我們簡單選一下這個svelte-notifications,看起來還可以,使用上也很簡單,就用這個來試試看吧:

svelte訊息套件的demo頁面

先安裝套件(記得要在app資料夾底下執行):

~/demo-app/app$ pnpm install -D svelte-notifications

一樣依notification的官方說明方法撰寫,它的用法是包住我們要顯示的元件,所以到layout裡加上:

<!-- app/src/routes/+layout.svelte -->
<script>
  import Notifications from 'svelte-notifications';
  // ... 略
</script>

<!-- 略 -->

<Notifications >
  <slot />
</Notifications>

你還記得嗎slot是我們svelte在切換前端不同路由時,代入不同頁面用的插槽,這邊我們把訊息通知物件加入在這邊,包住我們的頁面,之後所有路由裡的頁面就都可以呼叫使用。具體使用我們直接加到遊戲的頁面去試試:

<!-- app/src/routes/tic_tac_toe/+page.svelte -->
<script>
  import { getNotificationsContext } from 'svelte-notifications';
  const { addNotification } = getNotificationsContext();

  wsClient().onmessage = (e) => {
    addNotification({
      text: e.data.match(/>:(.*)/)[1],    // 訊息文字
      position: 'bottom-right',           // 訊息位置
      type: 'info',                       // 訊息框顏色
      removeAfter: 4000,                  // 自動關閉時間
    });
  };
  // ...略

</script>

addNotification 是解構訊息套件提供我們呼叫的方法,放到wsClient接到訊息的callback函數裡,呼叫訊息框的調用。這邊的match是使用JavaScript的Regex正規表達式,比對訊息文字,並取出>:後面的字串,也就是說篩掉前面User#0的部分,調整好後看一下我們前端的效果:

前端採用websocket即時顯示後的演示

正規表達式是在比對文字時很好用的一項工具,各種語言都有對應的Regex比對工具,如C#JavaScriptJava等,但是對可讀性來說真的不是太友善,比如說email的regex驗證長的像下面這樣:

(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])

很難看的懂吧,所以一般建議在寫regex時,除非很簡單:

  1. 不要盲目自幹,先找有沒有現成的
  2. 要寫註解,說明一下是在檢查什麼
  3. 給個範例,舉一兩個例子幫助理解
  4. 加上測試,正確和錯誤的範例都測
  5. 出了問題,修正之後還要補上測試

https

我們到現在使用的都是http,那麼如果是使用https的話呢,其實只要調整連線字串就好了,我們一起試試看,首先要把https專案跑起來,現在跑起來應該是會出現錯誤訊息:

https專案執行的錯誤訊息

我們當時加AppState時沒有一起加到https裡,這裡直接補上即可,除了加上程式狀態機,順便把訊息派送的方法也一起加上:

@@ web/src/bin/https.rs @@
+use web::app_context::AppContext;
+use web::web_socket::polling_message;
...
+let app_context = AppContext::default();
+polling_message(&app_context).await;
+let routers = routers::all_routers(app_context.clone()); 
-let routers = routers::all_routers(); 

不知道各位有沒有一點感覺了,有做好分層隔離的話,抽換不同的外層都不影響內層的實作,我們從http服務改用https服務,其實也改沒幾行程式碼。所以CA乾淨架構是身為軟體工程師必學的概念,在這裡置入一下,歡迎加入水球軟體學院,這裡是一個友善的環境,可以大家可以一起練功,一起追求進步。

回到程式主軸,在前端的的部分,其實連程式碼都不需要修改,只需要調整一下設定檔,改一下連線字串就好了。之前websocket協議是ws,加了密就跟http一樣加s就好,所以就改wss,我們放在.env檔案:

@@ app/.env @@
+VITE_API_BASE_URL=http://localhost:3031
-VITE_API_BASE_URL=http://localhost:3030
+VITE_WS_BASE_URL=wss://localhost:3031
-VITE_WS_BASE_URL=ws://localhost:3030

websocket改用wss連線正確顯示

clean up 打掃時間

到這裡告一個小小的段落,來進行一下程式的清理。

後端 cargo clippy

~/demo-app$ cargo clippy

執行檢查後問題比想像中的多(因為對rust還不是很熟),一個個調整並累積經驗值吧:

當然也可以cargo clippy --fix 直接自動修正,不過我們還是看一下當作學習。

提示length comparison to zero

這裡長度為0改用is_empty語意比較清晰,我們直接修改。

+if value.won_line.is_empty() {
-if value.won_line.len() == 0 {

下一個:
提示unused import: service::logger::Logger

這裡多了一個引用Logger的訊息,但我們應該有使用到Logger呀,怎麼會沒用到看一下原始碼的部分,原來是使用的時候沒簡化:

use service::logger::Logger;
// ...
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // ...
    let _logger = service::logger::Logger::builder().use_env().build();

這裡我們調整下面讓主程式碼的可讀性高一點:

+let _logger = Logger::builder().use_env().build();
-let _logger = service::logger::Logger::builder().use_env().build();

下一個:
this impl can be derived

這個Game我們之前應該跑過clippy,為什麼還會出現,原來是我們當初重構有拿掉欄位,而現在rust很聰明地可以判斷出我們實作的 Default 可以簡化:

impl Default for Game {
    fn default() -> Self {     
        Self {
            cells: [None; 9],
            is_over: false,
            winner: None,
            won_line: None,
        }
    }
}

每一個欄位都是預設的,比如Option預設是Nonecells每一格是Option,所以也是None,boolean預設是false,所以這裡的型別的自動預設值和我們手動設的一模一樣,難怪rust請我們使用derive來實現就好,不需要在這裡增加冗長的程式碼。我們刪掉整段 impl Default for Game 方法,到Game結構體定義上面加上derive(如果好奇的話,可以把false改true,就會發現clippy的警告消失了):

+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
-#[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct Game {

下一個:

unused variable: uid

這裡寫太開心(或是從其他地方複製貼上忘了調整(?)),這裡變數用不到,直接改成_

下一個:

useless conversion to the same type: u32

這裡發生一個看起來很笨的寫法,x是u32,然後還試著把u32轉換為u32,同一型別還要轉換好瞎。當時是在調整效能的時候,在試不同int大小時改來改去的,因為整個檔案用到的一起用multi-cursor進行修改,所以就沒看到這裡改成很奇怪的樣子。我們把try_from換掉:

+.map(|x| *x + 1)
-.map(|x| u32::try_from(*x + 1).unwrap())                  

前端 pnpm check

~/demo-app/app$ pnpm check

執行pnpm check的驗證結果

發現我們寫錯了,修正如下:

@@ app/src/api/tic_tac_toe.ts @@
+async deleteGame(id: number): Promise<void> {
-async deleteGame(id: number): Promise<unknown> {

把回傳的類別改為void就沒有問題了。

小結

不知道大家有沒有發現rust的工具鏈真的滿完整的,在編譯時很龜毛地檢查很多規則要符合才給過,但相對程式的安全性也提高了不少,潛在bug降低很多,還有clippy工具可以掃出寫不好的部分,也許邏輯對但有更好的寫法,並給予建議,幫助我們提高程式碼的品質。到現在我們使用接近TypeScript的高階語法來寫rust,就享受到了它的安全性和效能性(所以鼓勵大家一起來學rust)。

理論上有用CI/CD工具,更能幫助我們把關程式的品質,在程式碼推送進code base時,可以先讓程式自動檢查編譯有沒有通過,接著自動化跑測試,跑源碼分析cargo clippy,pnpm check等功能,避免依賴人工執行,依賴人工是不可靠的,一來不見得會記得,二來可能有人會想偷懶。這系列文章因為都手動執行,所以就會這次才會發現一些比較早以前留下的bug。記得程式的品質越早維護調整,後續的修改成本越小。

本系列專案源始碼放置於 https://github.com/kenstt/demo-app


上一篇
23 是websocket,不是socket。使用rust websocket
下一篇
25 使用Tauri派送訊息給Svelte
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言