對於像 BP Studio 這種演算法很複雜的應用程式來說,即使在開發階段已經非常努力地試著抓出並修正所有的錯誤,還是很難免會遇到某些很難觸發的邊緣案例(edge case)是會導致意想不到的錯誤的。這一篇當中來分享我如何對付這些潛在的錯誤。

核心分離

自從 v0.6 開始,BP Studio 在架構上除了用 Vue 寫成的 UI 部份之外,主體有兩個部份:

  • Client:在主執行緒上運行,負責用 pixi.js 進行繪製(參見第 15 篇),回應使用者在視圖區域上的操作,處理操作的邏輯(例如檢查輸入值範圍合法等等)與編輯歷史管理。
  • Core:核心,為獨立的 web worker 執行緒(每個開啟的專案各有一個),負責複雜的演算法運算(即之前第 16 篇所討論的範疇)、並且握有編輯中的專案之真正當前狀態。

這樣的架構設計有若干的好處,包括:

  1. 效能:Core 與 Client 是平行運作的,Core 的運算不會阻塞 Client 的繪製與回應使用者事件。
  2. 記憶體安全性:個別專案關閉的時候,可以直接把對應的 Core 關閉並釋放全部記憶體,100% 不會有記憶體洩漏的問題。
  3. 錯誤處理:可以把 Core 發生的未知錯誤跟主執行緒隔離開來。

而最後一點也就是本篇的重點。

Client 跟 Core 之間的生命週期可以用如下的圖來表示:

sequenceDiagram
    participant A as Client
    participant B as Core
    note over A: 使用者操作
    note over A: 檢查操作合法
    A->>B: 傳送操作請求
    note over A: (可以繼續預先處理下<br>一回合的使用者操作)
    note over B: 執行演算法
    B->>A: 回傳狀態更新
    note over A: 更新狀態
    note over A: 繪製

有一點值得注意的是,當使用者進行操作的時候,這個操作的合法性完全是由 Client 在把關的,Core 則永遠都假設 Client 那邊傳過來的請求是合法的、不會去做檢查。這樣設計的原因在於,如果操作的合法性是由 Core 在檢查的,一方面會比較慢才反饋給使用者、導致較差的 UX,另一方面也會不必要地增加 Client 與 Core 之間的 API 複雜度和實作難度。

於是,理論上只要進入到了 Core 這邊之後,演算法的部分都應該要能正確執行到回傳結果為止;任何在這個環境中發生的錯誤、都只能是因為我撰寫的演算法有漏洞所導致的未知錯誤。

因為是未知的,一旦任何這樣的錯誤發生,Core 當中的專案狀態就會處於一種我無法預期的不合法狀態,使得再繼續編輯這個專案的話、肯定只會出現更多的錯誤。因此,我在 Core 的生命週期的最上層加上一層 try/catch,並且在任何錯誤發生的時候,通知 Client 來提示使用者「有核心錯誤發生了」、並且把當前的專案關閉。Core 分離出來使得它的一切執行都有唯一的入口(即 onmessage 事件),所以很方便統一管理整體的 try/catch;無論發生了再怎麼出乎意料的錯誤,也能簡單地攔截到並確保 Client 不會受到影響。

自動恢復

只不過,雖然「一旦有核心錯誤發生就把專案關掉」這個政策有其合理之處,對使用者來說卻是不人性化的,因為他會因此遺失所有尚未存檔的進度。因此,在我發布 v0.6 版前夕的最後幾天當中,我臨時趕工針對了這個部份做了一些改進。

一開始,我先做到的是允許使用者把「專案最後已知為合法狀態的樣子」下載下來,而要做到這點,需要的就是每次正常從 Core 回傳回到 Client 之後,在 Client 這邊要保留一份最後狀態的副本(這發生在上圖中「更新狀態」的最後面)。但是經過了幾輪的 alpha 測試之後,我跟測試者之間一致同意、這樣其實還不夠方便。與其讓使用者下載備份狀態然後再自己手動開啟,我乾脆就一條龍地幫使用者自動重新打開備份的狀態就好了,於是最後這個機制就變成了自動恢復功能。

當然,這個自動恢復功能裡面有一個隱約的假定是,如果 Core 一路執行到回傳都沒有丟出錯誤,那麼 Core 最終傳回的狀態也會是合法的。換句話說,如果 Core 丟出錯誤的話,bug 發生的時間點一定是在最後這一回合的執行當中,而不會是發生在更早的回合當中。

只不過,這個假設當然是有點太過大膽了。現實中,演算法當然有可能在更早的回合就已經有 bug 出現,只是那個 bug 還沒觸發執行階段錯誤而已。如此一來,我備份的最後一個狀態也有可能已經是不合法的了,此時就會發生恢復失敗的情況。儘管如此,但我也沒有什麼更好的辦法,因為要徹底杜絕這個問題的話,大概就是兩條路:

  1. 把每一回合的狀態全部都備份下來;但這樣感覺也太浪費記憶體了 1
  2. 在備份狀態之前,先模擬一次載入的動作、確定即將備份的狀態是可以被正常載入的,然後才存入。
    這樣做雖然可以達到目的,但是會讓運算量爆增許多,因為會變成每一回合都要執行專案的完整載入。一個可能的解法是加以排程、使得當前的核心閒置的時候才去執行這樣的檢查,但是這樣又讓實作變得太複雜。

所以最終我還是只去備份最後一回合的完整狀態就好。

回報錯誤

到這邊雖然已經算是堪用了,但是演算法有 bug 的話、終究是需要加以修正才是負責任的開發態度,所以當有這種核心錯誤發生的時候,我會希望能夠知道。不過,BP Studio 是一個純前端的應用程式,它自己並沒有任何後台,所以為了要自動回報錯誤,就必須仰賴一些第三方的服務。之前我在第 14 篇當中就曾經有暗示過、我有在用 Google Analytics 來回報啟動錯誤,其具體的作法就是在 GA 裡面透過自訂維度來收集資料。當有錯誤發生的時候,我就把跟錯誤有關的診斷訊息寫在自訂維度的欄位當中 2,然後就可以在自訂報表當中看到該維度的內容。這樣做雖然有點麻煩,但好處在於 GA 有實作很好的離線後重新連網時的補登機制,可以確保錯誤不會被遺漏回報。

不過,這種做法要收集啟動錯誤還勉強堪用,但要用來收集核心錯誤的診斷資訊就顯得很不足了。GA 頂多只夠我收集錯誤訊息以及 stack trace,而為了有效診斷核心錯誤,我更是需要知道專案的狀態、以及最後的幾次操作到底做了些什麼。

為了達到這一點,我修改了一下稍早提到的「下載備份狀態」功能、變成是「下載錯誤 log」的功能。這個檔案除了包含專案的備份狀態之外,也包括了歷史操作紀錄、Client 最後一次傳送給 Core 的請求細節、以及發生錯誤時 Client 與 Core 兩方面的 stack trace。於是,當核心錯誤發生的時候,就會有彈跳視窗提示使用者「即將嘗試自動恢復專案,但在這之前請下載錯誤 log 並回報給作者」這樣。

主動上傳 log

OK,到這邊似乎已經很理想了:有自動恢復,也很貼心地可以讓使用者下載錯誤 log,接著我只要等有使用者遇到錯誤之後傳檔案給我診斷即可了,對吧?

偏偏現實世界就是沒有那麼順利。從我建立這套機制以來,即便確實有若干使用者曾經觸發過核心錯誤(如 GA 所示),截至本文寫成為止,真的傳 log 檔過來給我的人,除了當初的 alpha 測試者之外,一個也沒有。這個結果說明了,普遍來說人真的是很懶惰的。絕大部分的使用者心態,我猜啦,應該都是「啊沒差應該會有別人回報」或者「作者自己會注意到吧」或者「算了反正這個錯誤又不常遇到,也許只是偶然 3」之類的,總之就是不覺得有必要特地去跟作者回報錯誤。

這實在是很讓人寢食難安的狀況:有人觸發了核心錯誤表示程式裡面真的是有 bug 錯不了,但沒有人回報 log 的話,我連到底發生了什麼錯誤都不清楚,更別說偵錯了。雖然這會讓我在意得睡不著覺,但是沒有辦法,人性就是這樣。好吧!那就只好靠科技解決了!

在那個時候,從 GA 上頭觸發核心錯誤的頻率來看,大概平均兩週只會有一次,這說明了該核心錯誤是非常難以觸發的,只有在非常特定的操作之下才會發生,所以我必須要把握每一次觸發的機會、主動收集資訊,不能再被動地期待會有人回報 log 給我了。

於是我的目標就是:只要一觸發核心錯誤,就想辦法自動上傳 log 並且在 GA 當中留下紀錄。然而,要上傳到哪裡呢?上傳到我自己這台主機當然是一個顯然的解答,這會需要我寫一個簡單的後台 API 端點來接收上傳的檔案、並且儲存在伺服器上。然而,我對這個作法有兩點顧慮:

  1. 我希望 BP Studio 維持著純前端應用程式的樣子、而不要有任何後端成份,以便未來可以更容易地佈署到不同的環境上,若有需要的話。
  2. 我需要考慮到萬一這個 API 被濫用的情況。如果我寫的 API 程式有任何安全性漏洞,可能會導致被上傳惡意檔案並且被執行的情況;這種事情是連一次都不能允許發生的。偏偏我又不敢說我是這個領域的專家。

因此,我轉向第三方的檔案上傳服務,目標希望的是完全免費、有支援 CORS 的檔案上傳 API、然後空間不用太大也沒差。我最後選擇的是 Filestack 這個服務,它的免費方案提供 1G 的儲存空間、單一檔案最大 20MB、與每個月 500 次的檔案上傳,基本上遠遠高過我的需求。

更新:Filestack 從 2025.5.31 之後停止繼續提供免費方案,因此後來我改採用 Discord API 來把 log 檔案上傳到指定的私人頻道當中。這個作法既免費而且還會主動跳通知,使用體驗更好。

我加上了這套機制之後,終於在又等了大概兩週左右總算再次有兩個人觸發了核心錯誤,所幸整套機制運作非常順利,有完整的 log 自動上傳,這才讓我了解到了錯誤的原因。那兩個人當中,其中一個人是故意輸入了極大的數字讓演算法爆炸,於是我就加上了基本的上限來約束使用者的輸入;也難怪他不會加以回報,因為他自己也明白他是來亂的。但是第二個人觸發的錯誤就很可能跟之前其他人遇到的是一樣的了:那個錯誤是只有在做了非常特定的編輯動作、然後又按下了「復原」的歷史操作才會觸發的,確實是相當不容易在測試階段被發現的 bug。在修正了之後,我總算可以放心睡覺了。


  1. BP Studio 確實有編輯歷史的機制,但是那並不是把每一回合的完整狀態備份下來,而是只去記錄回合之間的差異。 

  2. 但是要注意的是,GA 的字串欄位的內容長度上限只有一百個字元,超過的部份就會被截掉,所以為了收集更多的資訊,會需要超過一個自訂維度、並且把要收集的訊息拆開填入。 

  3. 我覺得這個心態特別耐人尋味:很多外行人在面對科技的時候都會傾向於不去在意「偶然才會發生的錯誤或故障」,好像「大凡只要是機器、偶爾出錯也是很尋常的」,甚至連一堆從業者都有擺放乖乖在設備上、以祈禱不要出現故障的傳統。對於硬體、我承認有著很多不可預測性沒錯(電路板上的灰塵導致短路啦、蟲子爬進去啦……),但是如果搞軟體也這樣的心態那真的是荒唐到家了。我的心得是,軟體這種東西是有鐵律的:只要有任何的不尋常,就一定是程式有 bug 需要修正,絕對沒有什麼「偶然」這種事。 

我的文章對您有幫助嗎?請我喝杯咖啡吧:

分享此頁至:
最後修改日期: 2025/05/07

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。您的留言可能會在審核之後才出現在頁面上。