之前的文章「如何入門寫程式」中我分享了從完全沒學過程式一直到略懂的程度的建議法門,而這一篇就繼續在略懂寫程式了之後要繼續學哪些東西。

先聲明,如果你沒有打算靠寫程式吃飯,而純粹是把寫程式當作業餘興趣的話,我這篇談的東西你未必需要會也沒關係;因為老實說,我自己在轉職做工程師之前,這些東西我也是一知半解或甚至完全沒聽過,但也還是寫了一堆我上一篇文章中提到過的那些小程式。所以單就自己寫一些給自己用的小程式的這個目的來說,基本功大抵就夠用了;繼續學本篇的東西的用意,主要就是為了要寫難度較高、不太可能用土法煉鋼的方式寫出來的程式。當然,本篇的東西會了之後,再回去寫小程式也是會寫得更好、更輕鬆。

要形容本篇的內容的重要性,我不禁想起了近年來很紅的一個 YouTube 頻道 Primitive Survival Tool,主持的兩位柬埔寨神人完全不使用現代科技、純用土法和令人難以想像的毅力完成了一個又一個讓人瞠目結舌的建築專案(如果沒看過,你一定要點進去見識一下)。沒錯,用業餘的方法絕對也可以玩到神人級的境界,只是那種蓋房子的方法絕對沒辦法拿來當職業;首先實在太花時間,再來也不可能蓋得出高樓大廈。我這邊要講的,就是為了有效率地撰寫大型專案所必須要懂的知識。

此外,本篇的篇幅當然不可能把東西完整教會給各位,所以本篇的目的只是告訴大家有哪些東西是需要會的、以及為什麼你應該要會那些東西。而實際要學的話,就像我上一篇一開始就說過的「自學能力很重要」(笑)。

框架(framework)

既然剛才提到蓋房子,就用房子來比喻吧。如果我們去看街上大部分人住的房子,我們會發現很多共通的結構和元件,例如通常都會有「客廳、廚房、衛浴、臥室」這樣的格局分配,牆壁通常都是直角相交的,衛浴裡面基本上都會有馬桶跟洗手台,各個房間大多會有窗戶,房間內牆通常都是油漆或是貼壁紙,照明通常是裝在天花板上等等。這些共通點當然不是放諸四海皆準的,從來就沒有法律規定說房子一定要這樣蓋,世上的房子也一定找得到很多不是這樣蓋的的例子,但是長期下來,基於實用上的考量和人們的文化習慣等等,在建築圈裡面自然地就形成了前述的通則。當建築設計都依循著常見的通則在走的時候,施工的時候就有標準的流程可以走,建築工人的術語概念可以相通,而且很多材料零件都可以通用,蓋起來就有效率得多。然而,台灣民宅的通則跟日本建築的通則又明顯很不一樣;例如日本的房子很多都是木造的,但在台灣幾乎都是鋼筋水泥磚頭的。熟悉日本房子蓋法的建築工人,未必蓋台灣的房子也有同樣的熟練度。

寫程式的框架大抵來說也是這麼回事。雖然程式語言本身提供我們更多天馬行空的可能性可以寫出千奇百怪的程式出來,但是軟體工程演變至今,人們注意到一些需求和模式反覆地出現,於是發展出了一套套的框架來讓寫程式變得更容易。框架通常規範了整套程式是由哪些主要結構所組成、程式的脈絡應該如何運行、並且提供了各種工具來滿足一些常用的需求。熟悉一套框架之後,大部分的常見功能都只要照著框架設計的方式去做就可以很快地完成,而且其他也熟悉同樣框架的工程師要看懂或維護你寫的程式碼也會容易許多。你仍舊可以在框架的主體之上完全自由地寫一些額外的功能,但是框架會幫你在最短的時間之內把程式的大架構打穩。

舉一些具體的例子。以網頁來說,寫程式的時候常常會需要找出頁面上滿足特定條件的元件並對它們做一些操作,而歷史悠久的 jQuery 這個框架把前述的需求濃縮到很精簡的語法裡面,讓我們可以用較少的程式碼表達出我們想做的事情。近年來,我們又希望當記憶體中的資料改變的時候,頁面上的呈現可以自動也跟著改變,以省去我們寫一大堆程式去變更頁面來呈現資料的麻煩;因此就出現了幾套反應式框架像是 React 和我個人偏好的 Vue。在伺服器的部份,Express 框架可以讓我們輕鬆在 Node 環境上面撰寫伺服器程式。而在桌面應用程式方面,Electron 這個框架讓我們可以直接用網頁來撰寫桌面應用程式,且產生的應用程式可以跨平台執行。這些不同的框架背後的程式語言都是 JavaScript,但是它們針對不同的需求、各自提供一整套完整的基礎功能來讓我們更容易地寫出對應的程式。

有些框架會相依於另一個框架,也就說它需要另一個框架先安裝了才能使用。例如在 C# 語言之中,所有的程式都是基於 .NET 這個框架在運行的,所以其它 C# 的框架,包括 Entity FrameworkASP.NET MVCBlazor 等等,都是相依在 .NET 框架上的。除了相依性之外,應用程式也經常同時使用多個框架,例如你可以用 Electron 來打造應用程式的骨架、然後再用 Vue 來處理其互動性。

非程式語言的 CSS 也有例如 BootStrap 這樣的框架,它針對了常見的排版需求、定義了一些版型讓設計者可以快速套用。當專案負責美工設計的人和負責製作頁面的人都同樣熟悉 BootStrap 的時候,傳遞畫面定義也變簡單了,因為就直接說例如「這邊放一個 card」就懂是什麼意思了。

不管你正在使用哪一種程式語言,都可以去了解一下該語言中有哪些時下流行的框架,並且至少熟悉其中一種,這樣可以讓你在進入職場的時候更容易快速開始工作並方便跟同事接軌。

程式庫(library)

如果說框架就好比是蓋房子的整體規範,那麼程式庫就有如是房子裡面的家電。程式庫是為了實現一組特定功能而預先寫好的程式(可能是原始碼或編譯過的檔案如 dll 檔),安裝到專案之中便能讓自己的程式獲得該程式庫提供的功能。框架通常著眼的是整個程式的大架構,而程式庫則是負責專門的功能,像是專做圖形處理、專做資料驗證、提供特定的互動元件、或是串接特定的服務等等。

要使用程式庫提供的功能,你不需要理解其內部程式碼的運作原理,你只需要知道如何正確呼叫它或者把它載入到你的程式中即可。這些程式庫的使用方法,我們稱之為是程式庫的 API 1。程式庫的 API 規格通常會詳細列在作者的網站上,或者有參考文件跟程式庫一起發行。

就如同框架有相依性,有些程式庫也會依賴特定的框架才能使用,像是我寫的 gulp-workbox 就是專門給 Gulp 用的,而 jQueryValidation 顧名思義就是要有 jQuery 才能用。而就如同家電有眾多品牌,通常一種特定的功能也會有非常多不同的程式庫在試圖滿足這一項功能,因此寫程式的時候也需要稍微貨比三家一下來決定應該用哪一個。例如我的 Clickout-Event 是負責偵測「點擊在指定元件以外的範圍」的行為用的,做這件事情的程式庫至少有十幾個之多(不過既然我敢拿出來說,那就表示我有自信我的程式庫寫得比他們的都要更好用)。

套件管理器(package manager)

前面我們提到框架跟程式庫都有相依性的概念存在;除此之外,它們也都有版本的概念;框架或程式庫的作者常常會推出新的版本來提供新的功能或是修正一些錯誤。如果程式庫 A 依賴程式庫 B,然後 A 跟 B 各自推出了新的版本,且新版的 A 再次依賴於新版的 B,那你在更新的時候就必須要知道要同時更新 A 和 B 才行。數量不多的時候這還有可能手動進行,但是如果把所有的相依性整理起來,一個專案總共引用了上百個程式庫是很平常的事 2,這個要手動管理近乎是不可能的。

因此,各種程式語言現在都有它們的套件管理器。一個「套件」是指框架或程式庫打包之後的產物,裡面除了其程式本身之外,也會加上靜態資源、版本資訊、它所依賴的其它程式庫名稱、作者和版權資訊、簡短的使用說明與範例等等。而套件管理器則是幫助我們自動安裝和更新套件的工具程式。JavaScript 有 NPMYarn,Python 有 pipconda,C# 有 Nuget,PHP 有 Composer,諸如此類的。你只要告訴套件管理器去安裝某個套件,如果它背後有相依於其它套件,那些也會同時自動下載並安裝最新版到你的專案之中;更新特定套件的時候也是如此,而且如果某個先前相依的套件如今不再需要,也會自動幫你移除以節省專案大小。

大部分的套件管理器都沒有內建 GUI 而需要在命令列介面當中操作,除了 Visual Studio 本身有內建一些 GUI 之外。VS Code 也有一些套件管理器的 GUI 外掛可以用。

隨著你寫的程式越來越多,有很大的機會是你也會需要製作至少是你自己要用的套件,因此很建議針對你最常寫的程式語言去了解一下如何開發對應的套件、以及發行到套件管理器網站上的流程。

版本控制(version control)

常玩電腦遊戲的人大概都聽過「謝夫羅德大法」:就是存檔跟讀取啦!玩到一個進度的時候就記得存檔一下,如果接下來玩到炸掉,那沒關係,讀取存檔從上次沒問題的地方繼續玩就好了。

有趣的是,寫程式也有這樣的需求。今天當我們把寫程式當成職業的時候,做出來的產品不再是由軟體工程師自己來設計,而是會有專案經理根據客戶的需求來規劃,軟體工程師變成是負責實現需求的人。然而,實務上我們經常會需求的變更;幾天前說要這樣弄,但是過了幾天突然又因為某些原因要更改設計了,這種狀況要完全避免是很難的。雖然有點挫折覺得這幾天的程式白寫了,但是也沒辦法,還是得先改回到之前的樣子然後再根據新的需求重新寫程式。要命的就在這裡:如果幾天前的版本沒有先另外儲存起來怎麼辦?我們怎麼能夠百分之百記得這幾天到底改了哪裡、然後正確無誤地還原到幾天前的樣子?

在沒有版本控制系統出現之前,要因應這種可能的狀況,唯一的辦法就是每隔一段時間把整個專案的檔案完整地備份到獨立的資料夾中。但是首先整個專案其實 90% 以上的程式碼都沒有改變卻要完整備份、這即便是做成壓縮檔也很浪費空間,再來我們也很難在必要的時候比較不同時間點的備份到底差異在哪裡,不容易確定哪一個備份才是我們要找的。

除此之外,大型的專案也往往不會只有一個工程師,而可能有好幾個人同時在寫不同部份的程式碼。當他們各自在修改的時候,暫時就會產生好幾個不同的分支版本,如此一來我們要追蹤正確的版本就又更困難了。而當我們最後要把不同工程師的成果統整起來的時候,我們還要很清楚每個人各自修改了那些部份,不然合併的時候一定會有遺漏。這些要手動管理都是很麻煩的。

版本控制系統就是為了解決諸如此類的版本管理需求才誕生的東西,而其中 Git 是目前最多人在用的版本控制系統。當我們把一個專案資料夾變成 Git 存放庫之後,Git 會開始追蹤一切在這個資料夾裡面發生過的檔案變化,並且把各版本之間的改變以差異變更的形式儲存一個隱藏資料夾中,大幅節省了儲存空間,而且可以自動顯示出不同版本之間的差異在哪裡。當我們修改到一個差不多的地步時,我們可以「認可」變更,這個動作就好比遊戲中建立儲存點一樣,未來有必要的時候我們就可以自動還原整個資料夾的狀態到某次指定的認可之上。同時,Git 也可以讓我們創造專案的分支,即平行的版本,並且在必要的時候很輕易地合併不同的分支所作的修改。

最後,Git 也能夠讓我們把存放庫放到雲端空間,於是在不同的電腦上工作的各個工程師只要連線到雲端存放庫,就可以同步專案的狀態,大家都可以馬上看到別人做了哪些修改。著名的網站 GitHub 就是一個免費可以讓人上傳 Git 存放庫的空間。就算是業餘寫程式,把自己的專案上傳到 GitHub 上、也可以方便防範因為各種意外而導致本地檔案損壞的狀況,在雲端上永遠有備份。

Git 的預設操作也是在命令列中進行,不過除了 Git 本身內建有一套 GUI 之外,現在的 IDE 大多都有和 Git 整合,可以直接使用它們內建的 Git 功能操作認可、同步、比較編輯差異等等。

偵錯(debug)

對於夠複雜的專案來說,就算是寫程式的大神也很難完全避免程式寫錯。編譯階段的錯誤當然是容易抓到的,因為編譯器自然會告訴我們哪邊語法寫錯了、沒辦法編譯成功,麻煩的是執行階段的錯誤:程式能跑,可是跑出來的結果跟我們想要的卻不一樣,或是可能會當掉、閃退、跑太慢、出現錯誤訊息等等。要診斷執行階段的錯誤,只有靠自己的偵錯本領,所以這個是一定要鍛鍊的能力。

大部分的 IDE 都有提供偵錯工具,讓你可以在偵錯模式之下執行你的程式,透過中斷點、逐行執行、即時運算、變數內容查看等功能來逐漸縮小範圍、鎖定程式是從哪一個步驟開始跟你心中所想的執行結果有所出入,進而找出導致執行階段錯誤的原因並修正。如果程式執行得太慢,你也可以用同樣的方法找出是哪一段程式碼特別花時間,然後再設法加以改進。

不過,由於偵錯模式是一種特殊的直譯式執行模式,在一些少見的情況中其表現可能會跟實際執行的時候有一些差異,而偵錯工具在檢視計算屬性的時候可能也會產生一些副作用,這些差異可能導致實際執行中發生的錯誤無法在偵錯模式中復現,或是反過來在偵錯模式中出現的額外的錯誤。另外,也有一些情境是我們很難或甚至無法在偵錯模式之下執行程式的 3、一定得正式發行才能執行。基於這一類的理由,我們也需要懂得如何在正式執行中進行偵錯,而最標準的辦法就是在程式執行的同時讓程式在一些關鍵的步驟中輸出記錄(log)。記錄可以輸出到主控台命令列、文字檔、或是我偏好的方式是輸出到資料庫中,而透過記錄內容的設計,我們一樣可以了解程式的執行步驟以及階段性的變數內容等等。

測試(test)

當我們要寫一個有點規模的程式的時候,我們基本上不太可能一口氣把整個程式從頭寫到尾,大抵都是基本架構先寫好,然後再逐漸地把需要的功能寫上去,而過程中常常又要回去修改之前寫過的部份以便可以加上更多功能。每個階段我們寫好一些功能之後,自然我們會稍微測試執行一下看看是否功能運作正確然後再繼續,不過經常會有一種情況是我們稍後做的修改不小心沒有兼顧到之前寫的功能、而使得之前的功能如果再次測試就會出問題的;這尤其在程式有大改版的時候很容易發生。

為了避免這種現象發生,但是又為了避免每次修改程式之後都得手動把所有的功能都測試一遍,我們就要會寫測試程式。測試程式是一個和你實際上在寫的主要應用程式平行存在的另一個小程式,它會用一些事先設定好的步驟和資料(稱為測試案例)去執行主要應用程式(可能是全部、也可能是僅其一小部份),然後看看執行的結果跟預期的結果是否相符。當測試程式發現主程式針對某項測試案例的執行結果不如同玉其時,就會回報錯誤給我們知道。測試程式可以在很快的時間裡面把整個主程式的所有功能 4 測試一遍,確保我們在開發的過程中一路上都沒有不小心動到之前寫過的功能。

測試程式可以完全自己寫,但在各種程式語言中大多也都有專用的測試框架(例如 JavaScript 中的 MochaJest,Java 的 JUnit,C# 的 MSTest、NUnit 和 XUnit 等等);這些測試框架除了幫我們打好基礎之外,另一個非常重要的好處在於它們常常跟 IDE 會有比較好的整合,例如會在某個地方顯示出當前專案中所有的測試項目,以便我們可以很方便地單獨執行其中的某些測試項目、而未必需要每次都把所有的測試全部跑一遍(對於大型專案來說,這樣做可能是會需要花一點時間的)。

如果一項測試牽涉到的程式碼非常大一串,就算測試結果沒通過,我們也難以立刻從那一大串程式碼裡面鎖定問題是出在哪個環節上。因此在寫測試的時候,我們會盡可能地把程式的功能切割成一小塊一小塊的,然後分別加以測試(因此,我們常會需要模擬每一個階段的資料輸入輸出);這種把程式功能分割到最小單位再分別進行的測試就稱為單元測試(unit test)。要能夠寫單元測試,當然主程式本身的架構也必須要方便支援,例如把一大串很長的工作拆成若干個函數或方法,以便這些函數方法可以分開來被呼叫、進行測試。

除了單元測試,還有整合測試(integration test,測試單元之間整合起來的結果是否正確)和端對端測試(E2E test,指使用者真的去操作應用程式的功能,也就是測試應用程式的最大整體)。整合測試寫起來跟單元測試大同小異,只是覆蓋的程式碼更多;而端對端測試為了能夠在真實的執行環境中進行,也有專門的測試框架(例如 Cypress)可以撰寫自動測試。

例外處理(exception handling)

即便程式已經經過很多的測試,實務上還是很難完全避免發生一些開發者完全沒有預期到、測試的時候也沒有測出來的潛在例外可能性。所謂的「例外」,指的是程式在執行階段中遇到根本無法正確執行某一行指令、無法繼續的這種狀況(它同時也指具有例外處理機制的程式語言在遇到這種情況的時候會丟出的、包含有例外詳細資料的物件)。這種開發者沒有預期到的例外,尤其是在當我們把程式交給一個完全不會用的人亂用的時候很容易發生,因為此時使用者特別會去對程式做一些開發者沒想到的操作、或是提供奇怪的資料輸入。

最早年程式語言沒有內建例外處理機制的時候,如果程式執行過程發生例外,基本上要不是電腦直接當掉就是程式立刻閃退。雖然今天我們仍舊三不五時會遇到 App 閃退的情況,但是對於一個有充分應用例外處理機制的程式來說,這原則上是不應該發生的。對於那一類的程式,即使執行過程中發生了開發者沒有預期到的例外,程式也不會閃退,而能夠在提示使用者「操作有誤」或是「目前這項功能暫時無法使用」之後繼續讓使用者使用其它的功能,並且可能會在背景中把錯誤的細節回報給開發者,以便未來可能修正這項例外。

現在大部份的程式語言都有內建類似 try/catch 的語法可以接住程式丟出的例外並且加以處理;但是由於我們不太可能把我們寫的每一段程式碼都用 try/catch 包起來,有一些框架也提供了全域的例外處理機制設計,使得每當程式出現了漏接的例外的時候就自動進入到某一個地方來統一處理;此時我們就可以回覆一個預設的訊息給使用者,並且可能同時留下記錄以便開發人員可以檢視發生了什麼例外沒有被接住。

持續整合/持續部署(Continuous Integration/Continuous Deployment)

簡稱 CI/CD,這是一個對於大團隊開發的專案來說尤其很重要的概念。前面在介紹版本控制的時候提到,大團隊進行開發的時候經常會分出很多個不同的分支版本、以便團隊成員可以各自專注於自己負責開發的項目。這些不同分支的程式碼到最後都必須被合併到主要分支之上,而這個合併的動作如果拖得太久、各個分支的差異越來越大,就會變得非常難進行,所以一般都會盡可能縮短合併的週期(例如每天)、並且每次合併時都要確定整合之後的程式碼沒問題,這樣才能夠在有問題發生的時候及早發現並且方便修正;而修正了之後當然又要再次確認程式碼無誤……這個確認的過程我們當然也會希望可以自動化,而不是每次都需要手動進行,這就是所謂的持續整合(CI)。它通常會在每次進行完 Git 的推送之後自動執行如下的動作:

  1. 建置:確定程式碼在一個標準的環境中是可以建置完成的;講白話一點就是起碼編譯要能過才行,這是最基本的。
  2. 測試:確保新的程式碼仍舊可以通過所有的測試案例,也就是既有的功能都沒有因為撰寫新功能而受到影響。
  3. 程式碼分析:這可能包括程式碼風格檢查(以確定團隊的成員都用統一的風格來撰寫程式)以及弱點掃描(是否採用了某中已知有安全性弱點的寫法)等等。

在完成了 CI 之後,下一個步驟就是持續部署(CD,亦可指持續交付,即 Continuous Delivery),也就是自動把確認過沒問題、建置完成的應用程式發佈出去,且(特別是針對網路服務)持續監控應用程式是否正常執行。

常被提到的 CI/CD 工具包括 Jenkins、Drone、CircleCI、Travis CI、GitHub Action 等等。

結語

本篇中介紹的這些都是在職場上經常會被要求具備的技能,即使自己在練習寫小程式的過程中沒有感受到應用這些技能的必要性,也還是很建議有意轉正職的入門者來故意練習將它們引入到自己的專案之中,這對於未來參與大型專案會有很好的預備作用的。

當然還有太多的主題是這邊的篇幅無法一一介紹到的,像是重構、安全性、UI/UX 設計、演算法、軟體架構……這些也都是作為職業軟體工程師會需要多少了解的東西,要是有機會的話,未來會再分享在下對這些主題的心得。


  1. 你可能在串接網路服務的時候聽過 API(應用程式介面)這一則術語,不過 API 是泛指任何軟體跟軟體之間溝通的介面,所以這邊由於是你的程式主體要跟程式庫之間做溝通,因此其規格也是叫作 API。 

  2. 這其實也是因為套件管理器的發達才有這種現象;一旦套件管理變方便了之後,現在的程式庫有一種明顯的傾向是各自專注於非常特定的單一功能,然後再透過複雜的相依性把所有需要的功能像金字塔一樣堆積起來。程式庫越是專門化,也就越容易只引用那些我們需要的功能,也越容易分別確保各個程式庫的程式碼品質。 

  3. 例如我最常遇到的是這樣的情境:當我需要把站台佈署到公開網域以提供 callback 網址給第三方呼叫、但是我的網路環境又讓我無法架設反向代理來把網域指向偵錯程式的時候。 

  4. 一個相關的概念稱為程式碼覆蓋率(code coverage),也就是主程式的程式碼有多少被測試程式測試到了。理想狀況應該是覆蓋率達到 100%。 


分享此頁至:
最後修改日期: 2022/06/18

留言

撰寫回覆或留言

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