軟件再造:從意大利麵條到清潔設計
已發表: 2022-03-11你能看看我們的系統嗎? 編寫軟件的人已經不在了,我們遇到了很多問題。 我們需要有人為我們檢查並清理它。
任何從事軟件工程工作相當長的時間的人都知道,這個看似天真的請求往往是一個“滿是災難”的項目的開始。 繼承別人的代碼可能是一場噩夢,尤其是當代碼設計不佳且缺乏文檔時。
因此,當我最近收到一位客戶的請求,要求查看他現有的 socket.io 聊天服務器應用程序(用 Node.js 編寫)並改進它時,我非常謹慎。 但在奔向山丘之前,我決定至少同意看一下代碼。
不幸的是,查看代碼只會再次確認我的擔憂。 該聊天服務器已實現為單個大型 JavaScript 文件。 將這個單一的整體文件重新設計成一個架構清晰且易於維護的軟件確實是一個挑戰。 但我喜歡挑戰,所以我同意了。
起點 - 為再造做準備
現有軟件由一個包含 1,200 行未記錄代碼的文件組成。 哎呀。 此外,已知它包含一些錯誤並存在一些性能問題。
此外,檢查日誌文件(在繼承別人的代碼時總是一個很好的起點)揭示了潛在的內存洩漏問題。 在某些時候,據報導該進程使用了超過 1GB 的 RAM。
鑑於這些問題,很明顯,在嘗試調試或增強業務邏輯之前,代碼需要重新組織和模塊化。 為此,需要解決的一些初步問題包括:
- 代碼結構。 代碼根本沒有真正的結構,因此很難區分配置、基礎設施和業務邏輯。 基本上沒有模塊化或關注點分離。
- 冗餘代碼。 代碼的某些部分(例如每個事件處理程序的錯誤處理代碼、發出 Web 請求的代碼等)被多次復制。 複製代碼從來都不是一件好事,這使得代碼更難維護並且更容易出錯(當冗餘代碼在一個地方得到修復或更新但在另一個地方沒有得到修復或更新時)。
- 硬編碼值。 該代碼包含許多硬編碼值(很少是好事)。 能夠通過配置參數修改這些值(而不是要求更改代碼中的硬編碼值)將增加靈活性,還有助於促進測試和調試。
- 記錄。 日誌系統非常基礎。 它將生成一個巨大的日誌文件,難以分析或解析。
主要架構目標
在開始重構代碼的過程中,除了解決上面確定的具體問題之外,我還想開始解決一些對於任何軟件系統的設計來說都是(或至少應該是)共同的關鍵架構目標. 這些包括:
- 可維護性。 永遠不要期望自己是唯一需要維護它的人來編寫軟件。 始終考慮您的代碼對其他人的理解程度,以及他們修改或調試的容易程度。
- 可擴展性。 永遠不要假設您今天實現的功能就是您所需要的全部。 以易於擴展的方式構建您的軟件。
- 模塊化。 將功能分成邏輯和不同的模塊,每個模塊都有自己明確的目的和功能。
- 可擴展性。 今天的用戶越來越不耐煩,期望立即(或至少接近立即)響應時間。 糟糕的性能和高延遲甚至會導致市場上最有用的應用程序失敗。 隨著並髮用戶數量和帶寬需求的增加,您的軟件將如何運行? 儘管負載和資源需求增加,但並行化、數據庫優化和異步處理等技術有助於提高系統保持響應的能力。
重構代碼
我們的目標是從一個單一的單體 mongo 源代碼文件轉變為一組模塊化的、架構清晰的組件。 生成的代碼應該更容易維護、增強和調試。
對於這個應用程序,我決定將代碼組織成以下不同的架構組件:
- app.js - 這是我們的入口點,我們的代碼將從這裡運行
- config - 這是我們的配置設置所在的位置
- ioW - 包含所有 IO(和業務)邏輯的“IO 包裝器”
- logging - 所有與日誌相關的代碼(請注意,目錄結構還將包括一個新的
logs
文件夾,該文件夾將包含所有日誌文件) - package.json - Node.js 的包依賴項列表
- node_modules - Node.js 所需的所有模塊
這種特定方法沒有什麼神奇之處。 可能有許多不同的方法來重構代碼。 我只是個人覺得這個組織足夠乾淨,組織得很好,沒有過於復雜。
生成的目錄和文件組織如下所示。
日誌記錄
已經為當今大多數開發環境和語言開發了日誌包,因此現在很少需要“推出自己的”日誌功能。
因為我們使用的是 Node.js,所以我選擇了 log4js-node,它基本上是用於 Node.js 的 log4js 庫的一個版本。 這個庫有一些很酷的特性,比如能夠記錄多個級別的消息(警告、錯誤等),我們可以有一個可以分割的滾動文件,例如,每天,所以我們不必處理需要大量時間才能打開且難以分析和解析的大型文件。
出於我們的目的,我圍繞 log4js-node 創建了一個小型包裝器,以添加一些特定的額外所需功能。 請注意,我選擇在 log4js-node 周圍創建一個包裝器,然後我將在整個代碼中使用它。 這將這些擴展日誌記錄功能的實現本地化在一個位置,從而在我調用日誌記錄時避免整個代碼中的冗餘和不必要的複雜性。
由於我們正在使用 I/O,並且我們將有幾個客戶端(用戶)將產生多個連接(套接字),我希望能夠在日誌文件中跟踪特定用戶的活動,並且還想知道每個日誌條目的來源。 因此,我希望有一些關於應用程序狀態的日誌條目,以及一些特定於用戶活動的日誌條目。
在我的日誌包裝代碼中,我能夠映射用戶 ID 和套接字,這將允許我跟踪在 ERROR 事件之前和之後執行的操作。 日誌包裝器還將允許我創建具有不同上下文信息的不同記錄器,我可以將這些信息傳遞給事件處理程序,以便我知道日誌條目的來源。
日誌包裝器的代碼可在此處獲得。
配置
通常需要支持系統的不同配置。 這些差異可能是開發環境和生產環境的差異,甚至是基於需要展示不同的客戶環境和使用場景。
通常的做法是通過配置參數來控制這些行為差異,而不是要求更改代碼來支持這一點。 就我而言,我需要能夠擁有不同的執行環境(登台和生產),這些環境可能有不同的設置。 我還想確保測試代碼在登台和生產中都能正常工作,如果我需要為此更改代碼,那會使測試過程無效。
使用 Node.js 環境變量,我可以指定要用於特定執行的配置文件。 因此,我將所有以前硬編碼的配置參數都移到了配置文件中,並創建了一個簡單的配置模塊,該模塊可以加載具有所需設置的正確配置文件。 我還對所有設置進行了分類,以在配置文件上強制執行某種程度的組織並使其更易於導航。
這是生成的配置文件的示例:
{ "app": { "port": 8889, "invRepeatInterval":1000, "invTimeOut":300000, "chatLogInterval":60000, "updateUsersInterval":600000, "dbgCurrentStatusInterval":3600000, "roomDelimiter":"_", "roomPrefix":"/" }, "webSite":{ "host": "mysite.com", "port": 80, "friendListHandler":"/MyMethods.aspx/FriendsList", "userCanChatHandler":"/MyMethods.aspx/UserCanChat", "chatLogsHandler":"/MyMethods.aspx/SaveLogs" }, "logging": { "appenders": [ { "type": "dateFile", "filename": "logs/chat-server", "pattern": "-yyyy-MM-dd", "alwaysIncludePattern": false } ], "level": "DEBUG" } }
代碼流
到目前為止,我們已經創建了一個文件夾結構來託管不同的模塊,我們已經設置了一種加載環境特定信息的方法,並創建了一個日誌系統,所以讓我們看看如何在不更改業務特定代碼的情況下將所有部分聯繫在一起。

由於我們新的代碼模塊化結構,我們的入口點app.js
非常簡單,僅包含初始化代碼:
var config = require('./config'); var logging = require('./logging'); var ioW = require('./ioW'); var obj = config.getCurrent(); logging.initialize(obj.logging); ioW.initialize(config);
當我們定義我們的代碼結構時,我們說ioW
文件夾將包含業務和 socket.io 相關的代碼。 具體來說,它將包含以下文件(請注意,您可以單擊列出的任何文件名以查看相應的源代碼):
-
index.js
– 處理 socket.io 初始化和連接以及事件訂閱,以及事件的集中錯誤處理程序 eventManager.js
– 託管所有與業務相關的邏輯(事件處理程序)-
webHelper.js
– 用於執行 Web 請求的輔助方法。 -
linkedList.js
– 一個鍊錶實用程序類
我們重構了發出 Web 請求的代碼並將其移動到一個單獨的文件中,並且我們設法將我們的業務邏輯保持在同一個位置並且未修改。
一個重要的注意事項:在這個階段, eventManager.js
仍然包含一些真正應該提取到單獨模塊中的幫助函數。 然而,由於我們在第一階段的目標是重組代碼,同時最大限度地減少對業務邏輯的影響,而且這些輔助函數與業務邏輯的聯繫過於復雜,因此我們選擇將其推遲到後續階段,以改進代碼。
由於 Node.js 在定義上是異步的,我們經常會遇到一些“回調地獄”的老鼠窩,這使得代碼特別難以導航和調試。 為了避免這個陷阱,在我的新實現中,我使用了 Promise 模式,並專門利用了 bluebird,它是一個非常好的和快速的 Promise 庫。 Promise 將使我們能夠像同步代碼一樣跟踪代碼,還提供錯誤管理和一種標準化調用之間響應的干淨方式。 我們的代碼中有一個隱含的約定,即每個事件處理程序都必須返回一個 Promise,以便我們可以管理集中的錯誤處理和日誌記錄。
所有事件處理程序都將返回一個承諾(無論它們是否進行異步調用)。 有了這個,我們可以集中錯誤處理和日誌記錄,並且我們確保,如果我們在事件處理程序中有未處理的錯誤,則該錯誤被捕獲。
function execEventHandler(socket, eventName, eventHandler, data){ var sLogger = logging.createLogger(socket.id + ' - ' + eventName); sLogger.info(''); eventHandler(socket, data, sLogger).then(null, function(err){ sLogger.error(err.stack); }); };
在我們對日誌的討論中,我們提到每個連接都有自己的記錄器,其中包含上下文信息。 具體來說,我們在創建記錄器時將套接字 id 和事件名稱綁定到記錄器,因此當我們將該記錄器傳遞給事件處理程序時,每個日誌行都會包含該信息:
var sLogger = logging.createLogger(socket.id + ' - ' + eventName);
關於事件處理還有一點值得一提:在原始文件中,我們在 socket.io 連接事件的事件處理程序中調用了一個setInterval
函數,我們發現這個函數存在問題。
io.on('connection', function (socket) { ... Several event handlers .... setInterval(function() { try { var date = Date.now(); var tmp = []; while (0 < messageHub.count() && messageHub.head().date < date) { var item = messageHub.remove(); tmp.push(item); } ... Post Data to an external web service... } catch (e) { log('ERROR: ex: ' + e); } }, CHAT_LOGS_INTERVAL); });
這段代碼為我們獲得的每個連接請求創建一個具有指定間隔(在我們的例子中是 1 分鐘)的計時器。 因此,例如,如果在任何給定時間我們有 300 個在線套接字,那麼我們將每分鐘執行 300 個計時器。 正如您在上面的代碼中看到的那樣,問題在於沒有使用套接字,也沒有在事件處理程序的範圍內定義任何變量。 唯一使用的變量是在模塊級別聲明的messageHub
變量,這意味著它對於所有連接都是相同的。 因此,每個連接絕對不需要單獨的計時器。 所以我們已經從連接事件處理程序中刪除了它,並將它包含在我們的通用初始化代碼中,在本例中是initialize
函數。
最後,在我們處理響應的過程中,在webHelper.js
中,我們添加了對任何無法識別的響應的處理,這些響應將記錄有助於調試過程的信息:
if (!res || !res.d || !res.d.IsValid){ logger.debug(sendData); logger.debug(data); reject(new Error('Request failed. Path ' + params.path + ' . Invalid return data.')); return; }
最後一步是為 Node.js 的標準錯誤設置日誌文件。 該文件將包含我們可能遺漏的未處理錯誤。 為了將 Windows 中的節點進程(不理想,但您知道……)設置為服務,我們使用了一個名為 nssm 的工具,該工具具有可視化 UI,允許您定義標準輸出文件、標準錯誤文件和環境變量。
關於 Node.js 性能
Node.js 是一種單線程編程語言。 為了提高可擴展性,我們可以採用幾種替代方案。 有節點集群模塊,或者只是添加更多節點進程並在它們之上放置一個 nginx 來進行轉發和負載平衡。
但是,在我們的例子中,鑑於每個節點集群子進程或節點進程都有自己的內存空間,我們將無法輕鬆地在這些進程之間共享信息。 因此,對於這種特殊情況,我們將需要使用外部數據存儲(例如 redis)來保持在線套接字對不同進程可用。
結論
有了這一切,我們已經對最初交給我們的代碼進行了重大清理。 這不是要使代碼完美,而是要重新設計它以創建一個更易於支持和維護的干淨的架構基礎,這將有助於和簡化調試。
遵循前面列舉的關鍵軟件設計原則——可維護性、可擴展性、模塊化和可擴展性——我們創建了模塊和代碼結構,清楚地識別了不同的模塊職責。 我們還發現了原始實現中的一些問題,這些問題會導致高內存消耗,從而降低性能。
希望您喜歡這篇文章,如果您有進一步的意見或問題,請告訴我。