編譯 | Tina、核子可樂
現在是 2024 年,昨天 Leap Day Bugs 又來了,估計又有一些團隊迫于壓力熬夜改 bug 了。
2 月 29 日下午,有消息稱禾賽科技激光雷達存在固件 bug,緻使凡是用了禾賽激光雷達的車,自動駕駛功能全部歇菜。
禾賽科技是激光雷達頭部企業,其激光雷達交付量成功突破 5 萬大關,成爲了全球車載激光雷達行業首個單月交付量突破 5 萬的公司。對此,有媒體向禾賽科技官方求證,禾賽科技回應稱:" 有 2 個老款 L4 機械式激光雷達今天出現了軟件 bug。目前,問題原因已經找到,我們也跟相關客戶都做了深入溝通、并提供了相關解決方案。"
據稱該 bug 是個閏年問題。閏年是指該年有 366 日,即較平常年份多出一日。閏年是爲了彌補因人爲曆法規定的年度天數 365 日和平均回歸年的大約 365.24219 日的差距而設立的。多出來的一天爲 2 月 29 日。也就是說今年的 3 月 1 日晚了 24 小時,這種情況每四年發生一次。對于開發者來說閏年是一次小考驗,它強制要求大家必須在應用程序中考慮少見但不可避免的事件。
昨天,據禾賽科技表示他們 " 預計該問題會 24 小時内徹底解決。"
"24 小時 ",說長不長,說短也不短,但對程序員來說,這可能是要求他們通宵達旦、爆肝代碼的節奏。
而昨天因日曆上的小小變化而造成軟件 bug 和中斷問題的不止禾賽科技一家。雖然四年前才剛發生過一次,但顯然到現在還有很多公司沒有做好準備。
我們首先得點名的是 "OpenAI"。
多位網友反饋 OpenAI ChatGPT 3.5 認爲 "2024-02-29" 不是有效日期。由于此問題,至少有一名 OpenAI API 用戶在自己的應用程序中遇到了故障:
" 我們有一個通過 API 使用 ChatGPT 的産品,使用的是 3.5 Turbo 版本。我們的查詢涉及一些日期。今天它沒有像通常那樣返回文本,而是一直給出錯誤。"
新西蘭多處加油站遭遇自助支付終端問題。 據《新西蘭先驅報》報道:
" 該問題影響了全國所有無人值守的加油站,因爲新西蘭所有燃料公司都使用一家技術提供商 Invenco。原因是該系統未處理 2 月 29 日這一日期。在經曆了長達一天的閏年故障(刷卡支付機停機了 10 多個小時)之後,全國各地的加油站已重新恢複運行。"
" 我們清楚地知道閏年,"Invenco 首席執行官約翰 · 斯科特 ( John Scott ) 說。
" 過去 20 到 30 年來我們一直在與它們打交道。
"
哥倫比亞最大的航空公司打印的機票有誤。 阿維安卡航空公司 ( Avianca ) 打印的機票日期爲 3/1,而不是 2/29,因爲他們的系統沒有考慮閏日。一位旅客分享了該航空公司向客戶發送的電子郵件:
" 我們通知您,如果您的航班日期爲 2024 年 2 月 29 日,您的登機牌上的航班日期可能會存在差異。爲了确保您獲得正确的信息,請從 avianca.com 或我們的應用程序重新下載。"
印度新發布的智能手表無法顯示正确的日期。Fastrack FS1 是印度公司 Fatrack 最近發布的一款智能手表。FS1 型号于 2023 年 3 月發布。有多份報告稱該款手表在 2 月 28 日晚 11:59 後不再繼續跳動。
Fastrack 已經承認存在故障,并表示正在努力修複。但顯然這個問題花了 8 個小時還沒得到解決。
有用戶無法購買 YouTube Premium 訂閱。 年齡驗證邏輯認爲他們未滿 18 歲,因爲他們是在閏日出生的。這位用戶發帖稱,如果按照 YouTube Premium 計算方法,他們需要等到 70 歲之後才能夠購買。
EA Sports 賽車遊戲崩潰了。EA SPORTS WRC(世界拉力錦标賽)是一款拉力賽車遊戲,于 2023 年 11 月發布,适用于 Windows、Xbox 和 Playstation。今天這個遊戲顯然玩不了了:因爲它崩潰了。
鑒于遊戲行業比其他大多數公司在遊戲質量保證和測試方面投入更多,這次崩潰着實有點讓人難以理解。
EA Sports 建議的解決方法是 " 将你的系統日期設置爲 3 月 1 日 ,或者今天就休息一下!"
這個解決方案簡直是太出乎意料了,但也不是人人都打算忽視這個問題。有些開發者還是在認真修複這個 bug 的,對着這些開發者,我們借用網友的話來說,就是 " 值得緻敬 "!
這個 bug 怎麽修?
過往的閏年已經鬧出過不少影響巨大、引人注目的 bug。
例如:2012 年微軟 Azure 曾遭遇中斷,證書到期日期的計算錯誤緻使服務中斷達 12 個小時。2010 年索尼 PlayStation 網絡中斷的根源,正是系統将 2010 年錯誤識别成了閏年。2008 年微軟 Zure 設備集體 " 變磚 ",罪魁禍首就是 12 月 31 日邏輯錯誤。2008 年微軟 Exchange 管理 bug 導緻管理員在 2 月 29 日無法執行大部分操作。Lotus 1-2-3 對 1900 年的計算錯誤,直到 30 多年後的今天也仍是籠罩在微軟 Excel 頭頂的陰影!
這些還都是登上頭條的大新聞,我們相信肯定還有不計其數的小問題也曾發生,并在不同程度上影響到很多無辜用戶和項目開發者。
閏年 bug 随處可見,但在 C/C++ 代碼中惹出的麻煩最大,可能導緻應用程序崩潰或者緩沖區溢出(已經構成安全風險)。
危險性最高的兩大閏年 bug
#1: 在 C / C++ 中添加或減去年份
在使用 Win32 API 的 C/C++ 代碼當中,SYSTEMTIME 結構成爲常見的民用時間表示方式。它會将日期中的各個部分設爲不同的字段,具體分隔爲年、月、日值(及其他值)。下面來看常見的代碼表示:
SYSTEMTIME st;
// 聲明一個 SYSTEMTIME 變量
GetSystemTime ( &st ) ;
// 将其設置爲當前日期和時間
st.wYear++;
// 将值增加一年
上述代碼能夠順利運行,不會報出任何錯誤。但風險在于,如果在 2 月 29 日調用代碼,則結果值仍将是 2 月 29 日,但結果年很可能并非閏年。例如 2016-02-29 + 1 year = 2017-02-29,而 2017 年根本就沒有 2 月 29 号。
在最終被作爲另一項函數(例如 SystemTimeToFileTime)的參數之前,這個值可能會被傳遞多次,這會導緻函數失敗并返回零值。遺憾的是,很多方法都會直接使用上述代碼,而根本不對返回值進行檢查。這可能會引發無法預測的結果,例如将的 FILETIME 值保留爲未初始化狀态。
請始終檢查 Win32 函數的狀态結果,特别是 SystemTimeToFileTime。
檢查結果是否有效并在必要時進行調整,保證正确向 SYSTEMTIME 添加一年:
SYSTEMTIME st;
// 聲明一個 SYSTEMTIME 變量
GetSystemTime ( &st ) ;
// 将其設置爲當前日期和時間
st.wYear++;
// 将值增加一年
// 檢查是否爲閏年
bool leap = st.wYear %
4 ==
0 && ( st.wYear %
100 !=
0 || st.wYear %
400 ==
0 ) ;
// 如果值爲 2 月 29 日,但并非閏年,則回移至 2 月 28 日
st.wDay = st.wMonth ==
2 && st.wDay ==
29 && !leap ?
28 :
st.wDay;
請注意,标準 C++(非 Windows)代碼中也可能存在類似的 bug。這裏使用 tm 結構替代 SYSTEMTIME,因此具體操作略有不同。該結構中的月份值爲 0 到 11,而非 1 到 12,因此二月被标記爲 month 1。大家可以調用 _mkgmtime 來生成 time_t 結構,而非 SystemTimeToFileTime。二者的關鍵區别在于,tm 結構在運行至非閏年的 2 月 29 日不會報錯,而是直接生成代表 3 月 1 日的值。因此如果應用軟件計劃于 2 月 28 日截止,則需要進行調整。
#2: 爲一年中每一天的值聲明一個數組
int items [
365 ] ;
items [ dayOfYear -
1 ] = x;
以上 C 代碼可以輕松使用 C# 或者其他語言重寫,也可以使用字符串或者其他某種數據類型替換整數。其中的關鍵,在于我們會聲明一個固定大小的數組來保存數據,并假設一年中的每一天在數組中都有相應的單一位置。相信大家已經看出問題了,在閏年中,數組無法給第 366 天(12 月 31 日)留出位置。
由此産生的後果視編程語言而定。在 C# 中,這會引發 IndexOutOfRangeException 異常。在 C 語言中,除非啓用了邊界檢查編譯器選項,否則這會導緻緩沖區溢出——具體影響也就可大可小了。JavaScript 開發才倒是不用擔心,因爲語言會自動添加第 366 個元素。
數據過濾問題
閏年 bug 還會造成其他影響,比如影響到上一年 2 月 29 日到次年 3 月 1 日之間的任意數據。這種影響通常體現在數據過濾當中,比如範圍查詢不會考慮到額外的閏日——假設一年始終隻有 365 天,或者假設 2 月始終隻有 28 天。我們以下面的 SQL 語句爲例:
SELECT
AVG ( Total )
as AverageOrder,
SUM ( Total )
as GrandTotal
FROM Orders
WHERE OrderDate >= @startdate
AND OrderDate < @enddate
這條查詢很好,但如果把其中的 @enddate 設定爲今天,再把 @startdate 設置爲今年再減去 365 天,結果會如何。假設該範圍内恰好包含 2 月 29 日閏日,那它就無法涵蓋一整年。具體來講,開始日期少了一天,所以過濾得出的值不正确(假設用戶就是想篩出過去一整年的數據)。
在評估此類 bug 時,我們首先需要考慮 bug 的實際影響。具體來說,這些值會顯示在哪裏?如果系統隻是每天把平均訂單金額更新到儀表闆上的圖表當中,那造成的影響肯定不會像公司财務報告(比如上報給證券交易委員會的文件)中的當年總銷售額那麽重要。當然,bug 評估肯定要求大家熟悉應用軟件及其用法,所以實際操作還是要由各位靈活調整。
這裏我們推薦下面這種行之有效的解決方法:
TimeSpan oneYear =
TimeSpan.FromDays ( isLeapYear ( endDate.Year ) ?
366 :
365 ) ;
DateTime startDate = endDate - oneYear;
但這種方法也有其缺陷。僅通過評估年份,是無法确定具體需要添加多少天的。畢竟 endDate 有可能隻是 2016-01-01,所以盡管 2016 年是閏年,但隻需減去 365 天就能得到 2015-01-01。也就是說,我們還得考慮 2 月 29 日閏日是否被包含在範圍之内。如果嘗試手動執行,就得使用不少相當複雜的代碼。而且跨越的年數越多,具體實現就越麻煩。
究其根本,.NET 中的 TimeSpan(包括其他語言中的相似類型)表示的都是絕對時間,其中 " 年 " 和 " 月 " 屬于民用時間單位。一年或一個月的絕對時間量,将根據開發者描述的年份或月份而有所變化。(夏令時甚至對一天的定義都有浮動,但這就不在本文的讨論範圍内了。)
.NET 上的正确解決方案是:
DateTime startDate = endDate.AddYears ( -
1 ) ;
這裏的 AddYears 方法正确實現了所有必要邏輯,可以确定要向未來移動多少天,或者在取負值時代表向過去移動多少天。
在 JavaScript 中添加年份
JavaScript 開發者應該使用 moment.js 來實現這項功能,而且非常簡單:
var m = moment ( ) ;
add (
'years' ) ;
但有些人偏喜歡用更麻煩的方法行事,所以我們也經常會看到下面的方法:
var d =
new
Date ( ) ;
d.setFullYear ( d.getFullYear ( ) +
1 ) ;
這裏的問題前文已經提到了。如果今天是閏年的 2 月 29 日,則結果值将爲 3 月 1 日——可能有影響,也可能沒啥影響。畢竟對于其他所有日期來說,結果都跟原始值處于同一個月内。但請注意,如果你的應用軟件對月底和月初非常敏感,那就不行。
這裏大家可以使用以下函數在 JavaScript 正确添加年份,而無需調用完整庫:
function
addYears (
d, n ) {
var m = d.getMonth ( ) ;
d.setFullYear ( d.getFullYear ( ) + n ) ;
if ( d.getMonth ( ) !== m )
d.setDate ( d.getDate ( ) -
1 ) ;
}
// 用法示例
var d =
new
Date ( ) ;
addYears ( d,
1 ) ;
這就實現了添加年份,之後會檢查是否發生了轉至三月的情況。如果發生,則做出調整。再次強調,千萬不要具體計算需要添加的天數來解決問題——那更容易出錯,除非你真的很有經驗、清醒地知道自己在幹什麽。
其他常見錯誤
開發人員曾犯下過很多跟閏年相關的錯誤,例如:
弄錯了閏年算法。閏年絕對不是固定每四年一次,對于不能被 100 整除的年份才是每四年一次,能被 400 整除的除外。也就是說,1900 年并不是閏年。
爲每個月使用天數數組,其中二月隻有 28 天。使用此類數組時,必須考慮閏年的第 29 天。更好的辦法當然是爲閏年創建一套跟平年不同的數組,而一步到位的答案則是直接使用 API(如果可行),盡量别自己親自計算。
針對閏年爲代碼創建分支,但沒有測試所有代碼路徑。例如,Zune bug 的代碼頂部就有一個 ISleapYear(year)分支,但微軟顯然從來沒測試過該分支。
使用單獨的年、月和日值,但卻不對其進行驗證。例如,我們可能有一個帶有單獨下拉菜單控件的 UI,用于選定每個組件。隻測試某個日期在特定月份内是否有效還不夠,我們還得把年份也考慮進來。
直接使用一年的平均天數,比如日期數學中的 365.25 天或者 365.2425 天。雖然這在科學上比較準确,但卻根本不适合民用時間慣例。畢竟大多數用例根本就不在乎日期的值取到小數點後幾位。如果我們隻需要一個近似值倒是沒問題,但結果中的具體日期還是可能出錯。
如何發現閏年 bug?
認真檢查您的代碼,搜索一切跟時間相關的内容,然後仔細梳理。
确保進行充分的單元測試,并且了解如何正确 " 模拟時鍾 "(我們會在下一節中具體講解)。
全年測試,而非隻在閏年之前測試。
驗證所有輸入,包括配置部分。
驗證結果并完成場景,同時制定故障應對策略!
很多朋友還經常提到另外兩種方法:
靜态代碼分析
如果有一組工具可以針對現有代碼運行,并指出哪裏存在閏年 bug,那可就太棒了!但遺憾的是,我們還沒聽說過這樣的工具。唯一能想到的,也就是簡單的字符串搜索或者正則表達式搜索了。
.NET 真正需要的是一套全面的 Roslyn 分析器,它可以捕捉常見的日期 / 時間 bug,包括閏年、時區、夏令時、解析等。
同樣的道理也适用于 C++、Javascript 和其他編程語言——大家都需要,但就是沒有。
時間調節
爲什麽不把時間快進到下一個閏日,看看結果如何?在某些系統上,這樣确實可行。但其同樣存在一些問題。
我們的單元測試可能仍然無法捕捉到所有問題。除非大家手動查看整個應用軟件的每個屏幕和每份報告,否則很可能發現不了數據過濾 bug。沒發現的 bug 就是雷,早晚會炸。
這可能會帶來一種虛假的安全感,認爲快進後沒問題,那到時候也不會有問題。這種問題不止出現過一次,隻有客戶們在 2 月 29 日或者 3 月 1 日瘋狂打電話投訴時,你才會意識到自己太傻太天真。
很多系統都必須使用域服務器進行身份驗證,或者使用其他時間敏感的身份驗證方案。所以這裏要提醒大家,Kerberos 協議有着嚴格的時間同步要求,默認容差必須在 5 分鍾之内。另外還有 SSL 證書、代碼簽名證書和一系列其他安全機制,它們全都依賴于時鍾。所以如果試圖謊報時間,系統就會報錯。
所以總的來說,我們建議大家不要耍這種小聰明。
模拟時鍾
那該如何正确測試代碼在不同日期下是否表現有别?答案就是模拟時鍾。
這也是許多可靠系統中的常見模式。再次強調,用于顯示當前真實時間的系統時鍾絕不可随意使用。應用程序的邏輯永遠不該直接調用 DateTime.Now、DateTime.UtcNow、new Datte ( ) 、GetSystemTime 或者編程語言中任何同類項來獲取當前日期和時間。
相反,我們應該将時鍾視爲一項服務(在 DDD 領域驅動設計意義上);而且跟任何服務一樣,大家必須有辦法模拟時鍾。
舉例來說,在 .NET 中,不要從應用程序邏輯處直接調用 DateTimeOffset.UtcNow(或者類似的 API):
使用返回 DateTimeOffset 的方法 GetCurrentTime 來創建一個接口 IClock。
創建一個從 IClock 實現的 SystemClock 類,其中 GetCurrentTime 調用 DateTimeOffset.UtcNow。
創建一個從 IClock 實現的 FakeClock 類,該類接受固定值作爲構造函數參數,且其中 GetCurrentTime 僅返回該固定值。
在應用程序邏輯中,應僅依賴于 IClock 接口,且通常由構造函數進行注入。
在測試中使用 FakeClock,而在運行時上接入 SystemClock。
上面這一系列步驟聽起來有點麻煩,但隻要順利完成,大家就能感受到它的優勢所在。這意味着當前日期和時間都是依賴項,這也是保證所有代碼都能受測試覆蓋的唯一方法。
這裏我們沒有提供具體代碼,因爲在不同的編程語言中肯定有不同的實現,但思路和模式應該是共通的。另外,Noda Time 中已經提供非常好找實現,它在主程序集中直接提供 IClock 和 SystemClock,在 NodeTime.Testing 程序集中則提供 FakeClock。所以如果大家實在擔心閏年 bug,不妨直接使用 Noda Time。
閏年雖然不像當初的千年蟲那樣搞得舉世震驚,但它無疑也是個刺頭、而且每隔幾年就跑出來惡心人。過去四年間大家寫了多少代碼?敢保證一切都符合标準嗎?現在請花點時間掃描并測試自己的代碼,沒準會發現有些您沒意識到的隐患就潛伏在陰影當中。
參考鏈接:
https://codeofmatt.com/list-of-2024-leap-day-bugs/
https://newsletter.pragmaticengineer.com/p/happy-leap-day
https://codeofmatt.com/happy-new-leap-year/
聲明:本文由 InfoQ 翻譯,未經許可禁止轉載。