簡(jiǎn)史:如何掙脫 Windows 的枷鎖)
很多現(xiàn)在用 .NET Core 寫服務(wù)的年輕朋友可能已經(jīng)不太能理解“跨平臺(tái)”這件事為什么值得單獨(dú)寫一篇文章。默認(rèn)不就是dotnet build一下然后丟到 Linux 容器里跑嗎但如果你經(jīng)歷過.NET Framework4.x 時(shí)代或者曾經(jīng)被客戶要求在 CentOS 上部署一個(gè)老 ASP.NET 項(xiàng)目而勸退過就會(huì)知道“ .NET Core 跨平臺(tái)”這幾個(gè)字背后其實(shí)是整個(gè) .NET 生態(tài)跟歷史遺留包袱打了一場(chǎng)多年的戰(zhàn)爭(zhēng)。這篇是系列上篇不急著講 CoreCLR 和依賴注入那些事先聊歷史的枷鎖為什么當(dāng)年的 .NET 天生就長(zhǎng)在 Windows 上為什么跨平臺(tái)這么難又是什么力量把它一步步撬開的。適合看這篇的人大概是兩類一類是剛接觸 .NET Core、想知道“為什么以前不行、現(xiàn)在行”的新人另一類是還在維護(hù)老 .NET Framework 項(xiàng)目、想上云或者遷移到 Linux 的開發(fā)者。前者能少走彎路后者能看清坑在哪。只要你動(dòng)手做過一次跨平臺(tái)遷移對(duì)這些歷史包袱的體感就會(huì)完全不同。1. 從“Windows的唯一答案”說起.NET 的基因里刻著什么1.1 .NET 出生時(shí)的定位為 Windows 而生的“下一代開發(fā)平臺(tái)”很多人現(xiàn)在回頭看 .NET 1.0容易下意識(shí)拿它跟 Java 對(duì)比覺得“都是托管運(yùn)行時(shí)為什么 Java 一開始就跨平臺(tái).NET 不跨”。這其實(shí)是用今天的視角美化歷史。2002 年 .NET Framework 1.0 發(fā)布時(shí)它的定位根本不是“跨平臺(tái)”而是微軟押注 Windows 生態(tài)的下一代開發(fā)模型。那時(shí)候微軟的主要對(duì)手是 Java 和 Sun 的整套體系微軟要做的是把開發(fā)者留在 Windows 上讓 Windows 成為唯一合適的宿主機(jī)。這不是我事后總結(jié)出來的而是從 .NET 早期的架構(gòu)細(xì)節(jié)里能直接看出來的。CLR 在很大程度上被設(shè)計(jì)成操作系統(tǒng)的一部分而不是一個(gè)獨(dú)立運(yùn)行的應(yīng)用層運(yùn)行時(shí)。.NET Framework 的很多組件是跟著 Windows 補(bǔ)丁一起分發(fā)的Windows XP、Vista、7、8、10 都內(nèi)置了特定版本的 .NET Framework。這種綁定帶來的好處是開發(fā)者在部署時(shí)很省事系統(tǒng)自帶就能跑壞處是運(yùn)行時(shí)本身無法脫離操作系統(tǒng)獨(dú)立進(jìn)化。尤其到了 Windows 8 時(shí)代.NET Framework 4.5 幾乎就是 Windows 的一個(gè)系統(tǒng)組件你想換版本得看操作系統(tǒng)臉色。更麻煩的是.NET 的整個(gè)框架庫并不是空中樓閣。BCL 里大量功能的實(shí)現(xiàn)直接依賴 Win32 API。比如你寫一句File.ReadAllText底層很可能就是一次CreateFile加ReadFileRegistry.GetValue聽名字就知道只有 Windows 注冊(cè)表才有。一個(gè)運(yùn)行時(shí)如果能在 Linux 上跑 JIT充其量只是“肌肉”過來了“內(nèi)臟”還全是 Windows 的。這就是當(dāng)時(shí)所有跨平臺(tái)嘗試面臨的第一個(gè)結(jié)構(gòu)性障礙框架代碼比運(yùn)行時(shí)代碼更不平臺(tái)無關(guān)。1.2 組成 .NET 的幾根“綁定繩”P/Invoke、COM、WinForms 與 IIS如果只講“底層調(diào)用了 Win32”還是太抽象。具體一點(diǎn).NET Framework 的跨平臺(tái)障礙實(shí)際上是幾個(gè)大塊頭綁在一起的P/Invoke 機(jī)制本身不是問題問題在于它調(diào)用的大多是 Windows 專屬 API。連System.IO、System.Diagnostics.EventLog、System.ServiceProcess這種基礎(chǔ)命名空間都長(zhǎng)著 Windows 的臉。COM 互操作是 .NET 早期的重要賣點(diǎn)但 COM 幾乎等于 Windows 的代名詞。當(dāng)年大量企業(yè)組件、Office 自動(dòng)化、WMI 管理接口全依賴 COM這套生態(tài)天然排斥非 Windows 平臺(tái)。WinForms 和 WPF 是桌面 UI 的兩大王牌但它們分別依賴 User32、GDI 和 DirectX 渲染。把這些搬到 Linux 上等于把 Windows 的窗口管理器也搬過去工程量巨大。ASP.NET WebForms 和早期 ASP.NET MVC 的宿主是 IIS。IIS 本身是 Windows 服務(wù)請(qǐng)求管線、進(jìn)程模型、身份認(rèn)證都和 Windows 賬戶體系深度耦合。就算 CLR 能跨平臺(tái)Web 框架這層也過不去。這些綁定不是某一天設(shè)計(jì)出來的而是一層層疊加出來的。我見過不少朋友在 .NET Framework 項(xiàng)目里用ComponentDispatcher、HwndSource、RegistryKey這些類寫的時(shí)候沒覺得有什么問題一旦想換平臺(tái)才發(fā)現(xiàn)這些 API 的“老家”都在 Windows 的系統(tǒng) DLL 里。跨平臺(tái)不是編譯一次換個(gè)后綴名那么簡(jiǎn)單它意味著整個(gè)框架層的“地基”都要換。2. 掙脫的先行者M(jìn)ono 把“不可能”拉到“可能”2.1 Mono 的起點(diǎn)ECMA 標(biāo)準(zhǔn)是那把鑰匙在 .NET Core 之前“.NET 跨平臺(tái)”這個(gè)命題不是沒人做過最著名的就是 Mono。2001 年Ximian 發(fā)起 Mono 項(xiàng)目目標(biāo)是在 Linux 上提供一個(gè)獨(dú)立于微軟實(shí)現(xiàn)的 .NET 運(yùn)行時(shí)。它之所以能啟動(dòng)關(guān)鍵不是因?yàn)槲④洿蟀l(fā)慈悲而是因?yàn)槲④洶?C# 語言和公共語言基礎(chǔ)架構(gòu)CLI提交到了 ECMA也就是后來的 ECMA-334 和 ECMA-335。ECMA 標(biāo)準(zhǔn)公開之后第三方依據(jù)標(biāo)準(zhǔn)文檔實(shí)現(xiàn)自己的 CLR 和編譯器就成了合法且可行的事。Mono 的意義在當(dāng)年非常大。它讓 Linux 桌面應(yīng)用有能力使用 C# 開發(fā)GNOME 桌面里好幾個(gè)核心工具就是 Mono 寫的。后來的 Unity 游戲引擎也是把 Mono 作為腳本運(yùn)行時(shí)帶到了無數(shù)游戲里??梢哉fMono 是第一個(gè)證明“托管代碼可以脫離微軟親兒子運(yùn)行環(huán)境”的項(xiàng)目。它提前給社區(qū)埋下了一顆種子原來 .NET 的江湖不是只有微軟一家。但 Mono 也背負(fù)著沉重的歷史包袱。它要兼容的是 .NET Framework 的龐雜 API而這些 API 里有一大堆是 Windows 專屬的。Mono 團(tuán)隊(duì)選擇用各種方式“補(bǔ)兼容”能實(shí)現(xiàn)的實(shí)現(xiàn)不能實(shí)現(xiàn)的就拋異常或者給個(gè)空殼。這種“盡力兼容”的思路在小項(xiàng)目里很奏效碰到大型企業(yè)項(xiàng)目就力不從心。我們今天回頭看Mono 不是輸在技術(shù)不行而是輸在“它要復(fù)刻的對(duì)象本身就是一座 Windows 專屬的大廈”。2.2 Mono 的歷史貢獻(xiàn)讓跨平臺(tái)從“絕對(duì)不行”變成“看情況行”如果說 .NET Framework 的歷史枷鎖是把鎖鏈完全焊死在 Windows 上那 Mono 就是拿鑿子把鎖鏈敲松了一點(diǎn)。至少從它開始社區(qū)明確知道 .NET 是可以用另一個(gè)運(yùn)行時(shí)跑起來的只是兼容層會(huì)受傷。Mono 能跑通不少控制臺(tái)程序、類庫、網(wǎng)絡(luò)服務(wù)但 WPF、WCF 服務(wù)端、完整 IIS 管線這些大家都繞不過去。實(shí)踐中最常見的問題是開發(fā)機(jī) Windows 上編譯好好的 Web 項(xiàng)目放到 Mono 環(huán)境里因?yàn)槟硞€(gè)System.Drawing底層依賴跑不起來或者是數(shù)據(jù)庫驅(qū)動(dòng)、加密組件內(nèi)部走了 P/InvokeLinux 上沒有對(duì)應(yīng)的 native 庫整個(gè)服務(wù)直接崩掉。我當(dāng)時(shí)排查過不少這類問題最后多半都是繞路換 API而不是真正解決問題。所以說Mono 打破了“不可能”的絕對(duì)化但它并沒有打通一條系統(tǒng)性的官方路徑。它更像是江湖俠客靠個(gè)人武藝開路真正的官方隊(duì)要等到很多年后 .NET Core 出現(xiàn)才進(jìn)場(chǎng)。3. .NET Core 誕生的導(dǎo)火索云計(jì)算的倒逼與老后端的拖累3.1 2014 年的微軟轉(zhuǎn)身為什么要親手拆掉 Windows 綁定Mono 奮斗了十幾年微軟一直按兵不動(dòng)但到了 2014 年前后形勢(shì)變了。云計(jì)算興起之后服務(wù)器端的大量工作負(fù)載從 Windows Server 轉(zhuǎn)向 Linux企業(yè)采購開始更多依賴開源技術(shù)棧。如果微軟繼續(xù)把 .NET 鎖在 Windows 上那 .NET 在服務(wù)器端的市場(chǎng)份額只會(huì)越來越小。阿里、亞馬遜、谷歌的云上跑著海量 Linux 實(shí)例開發(fā)者想用 .NET 都沒地方放這是很現(xiàn)實(shí)的問題。所以微軟做了一系列轉(zhuǎn)向開放 .NET 官方實(shí)現(xiàn)源碼、成立 .NET Foundation、把 Roslyn 編譯器、CoreCLR、CoreFX 放到 GitHub 上。這一步的意義很直接微軟從“把 .NET 當(dāng)作 Windows 生態(tài)的一部分”轉(zhuǎn)變成“把 .NET 當(dāng)作微軟云上一種主流開發(fā)工具”。工具可以跨 Windows 和 Linux才能真正適應(yīng)混合云、多平臺(tái)部署的節(jié)奏。這在當(dāng)時(shí)被看作“微軟擁抱開源”的標(biāo)志性事件但在我看來本質(zhì)是商業(yè)利益驅(qū)動(dòng)的架構(gòu)松綁。這件事給開發(fā)者的沖擊很大。尤其對(duì)中小企業(yè)來說以前要跑 .NET 網(wǎng)站必須買 Windows Server 授權(quán)運(yùn)維成本比 Linux 高不少。很多創(chuàng)業(yè)團(tuán)隊(duì)選型時(shí)直接把 .NET 排除掉不是語言不好而是平臺(tái)成本太高。.NET Core 開源并跨平臺(tái)之后同樣一套代碼可以部署到便宜得多的 Linux VM 或容器里這才是它能重新獲得關(guān)注的根本原因。3.2 老框架的結(jié)構(gòu)性難題安裝式更新、GAC 和 IIS 的綁架既然要重寫一個(gè)跨平臺(tái)運(yùn)行時(shí)就得先清算老框架的歷史包袱。.NET Framework 最典型的問題有三個(gè)每一個(gè)都?jí)蜃屓祟^疼。第一是“機(jī)器級(jí)安裝”模型。.NET Framework 的運(yùn)行時(shí)是操作系統(tǒng)級(jí)別安裝的升級(jí)也是系統(tǒng)級(jí)升級(jí)。你在自己機(jī)器上裝一個(gè)新版 .NET 補(bǔ)丁可能會(huì)影響同一臺(tái)機(jī)器上的所有老應(yīng)用。企業(yè)環(huán)境里不同項(xiàng)目依賴不同 .NET 修補(bǔ)版本的情況非常常見一個(gè)應(yīng)用升級(jí)導(dǎo)致另一個(gè)應(yīng)用掛掉簡(jiǎn)直是運(yùn)維噩夢(mèng)。第二是 GAC也就是全局程序集緩存。程序集裝進(jìn) GAC 之后所有應(yīng)用共享看起來省空間實(shí)際上版本沖突讓人頭大。你想讓兩個(gè)應(yīng)用用不同版本的同一個(gè)第三方庫GAC 模式下很容易互相踩。這種共享依賴的更新方式放到現(xiàn)代容器化部署里完全行不通。第三是 System.Web 與 IIS 的深度耦合。老 ASP.NET 的請(qǐng)求生命周期是嵌在 IIS 里的進(jìn)程管理、線程調(diào)度、管道事件全跟 IIS 綁在一起。IIS 是 Windows 專屬服務(wù)所以雖然理論上 CLR 有移植可能只要 Web 框架這層不動(dòng)跨平臺(tái)就是空談。想從根上解決只能把 Web 框架也拆掉重寫這就是為什么 ASP.NET Core 沒有沿用 System.Web 的老模型。我印象特別深的是 .NET Framework 時(shí)代部署網(wǎng)站發(fā)布完往往還要在 IIS 管理工具里手動(dòng)配應(yīng)用程序池、權(quán)限、身份認(rèn)證。而到了 .NET Coredotnet run起來了Kestrel 自己監(jiān)聽端口想折騰就套一層 Nginx跨平臺(tái)自然就順了。這背后不是運(yùn)氣而是結(jié)構(gòu)設(shè)計(jì)變了。4. 拆掉“鎖鏈”的核心.NET Core 背后的架構(gòu)手術(shù)4.1 從“單一大包”到“模塊組件”CoreCLR 與 CoreFX.NET Core 能跨平臺(tái)根本原因是微軟沒有再走“一個(gè)大而全的 Framework”老路而是把運(yùn)行時(shí)和基礎(chǔ)庫拆成可獨(dú)立分發(fā)的模塊。CoreCLR 負(fù)責(zé) JIT、GC、類型系統(tǒng)這些運(yùn)行時(shí)核心CoreFX 提供System.*命名空間下的大部分基礎(chǔ)庫實(shí)現(xiàn)兩者都能隨應(yīng)用一起發(fā)布不依賴操作系統(tǒng)預(yù)裝。這套模型的直接好處是你的應(yīng)用可以自己帶一份運(yùn)行時(shí)部署到任意支持的操作系統(tǒng)上。不需要在目標(biāo)機(jī)器上先安裝特定版本的 .NET Framework也不用怕系統(tǒng)補(bǔ)丁把運(yùn)行時(shí)改掉。這種“app-local runtime”的模式在 Node.js、Go 和 Python 虛擬環(huán)境里已經(jīng)非常成熟.NET Core 把它吸收過來等于把歷史包袱甩掉一大半。同時(shí)模塊化也讓平臺(tái)專屬 API 顯形了。像 Windows 注冊(cè)表、事件日志、服務(wù)控制被抽到獨(dú)立的 NuGet 包里只有目標(biāo)平臺(tái)是 Windows 時(shí)才會(huì)使用??缙脚_(tái)代碼默認(rèn)不引用這些包這就從源頭上避免了“我明明寫跨平臺(tái)項(xiàng)目卻順手調(diào)了個(gè) Win32 函數(shù)”的尷尬。4.2 .NET Standard 與兼容層老項(xiàng)目遷移的“體檢清單”對(duì)老 .NET Framework 項(xiàng)目來說直接跳到 .NET Core 不現(xiàn)實(shí)所以微軟還搞了一個(gè) .NET Standard 作為中間契約。簡(jiǎn)單理解它就是一套跨平臺(tái) API 的公共子集。如果你寫的庫只用到 .NET Standard 里的 API那么這個(gè)庫可以被 .NET Framework、.NET Core、Xamarin 等多個(gè)運(yùn)行時(shí)共用。這不是魔法而是把歷史 API 里真正平臺(tái)無關(guān)的部分提煉出來做成一個(gè)大家都認(rèn)的“普通話”。當(dāng)然.NET Standard 不可能解決所有兼容問題。老項(xiàng)目里常見的 Windows 專屬依賴表格里列的比較清楚歷史包袱跨平臺(tái)影響.NET Core 后的處理WinForms / WPF僅限 Windows 桌面在 .NET Core 3.0 中繼續(xù)支持但仍是 Windows-onlySystem.Web / ASP.NET WebForms依賴 IIS 管線不兼容需要改用 ASP.NET CoreAppDomain 動(dòng)態(tài)加載平臺(tái)相關(guān)能力過大用 AssemblyLoadContext 替代更可控Windows 注冊(cè)表 / 服務(wù) / 事件日志調(diào)用 Windows 專屬 API獨(dú)立 NuGet 包僅 Windows 啟用System.DrawingGDI 底層 Windows 化跨平臺(tái)場(chǎng)景建議用 ImageSharp 等替代老項(xiàng)目遷移的時(shí)候我建議先把所有項(xiàng)目依賴用 API Portability Analyzer 掃一遍看看到底有沒有踩不能跨平臺(tái)的 API。這一步能省掉后面大量部署時(shí)才發(fā)現(xiàn)問題的痛苦。別指望 .NET Standard 幫你“自動(dòng)跨平臺(tái)”它只是給你劃了一條安全線越線的事還得自己處理。4.3 服務(wù)器層解綁Kestrel 替代 IIS跨平臺(tái)的最后一根鐵鏈在服務(wù)器層。老 ASP.NET 的宿主是 IISIIS 又是 Windows 專屬所以早期換平臺(tái)根本無從談起。ASP.NET Core 直接把 Kestrel 作為內(nèi)置的跨平臺(tái) Web 服務(wù)器Kestrel 是純托管實(shí)現(xiàn)的可以在 Windows、Linux、macOS 上直接監(jiān)聽端口處理 HTTP 請(qǐng)求。生產(chǎn)環(huán)境里你覺得 Kestrel 裸奔不放心就可以在前面放 Nginx 或 Apache 做反向代理靜態(tài)文件、負(fù)載均衡、HTTPS 終結(jié)這些都可以交給代理層。這個(gè)變化讓 .NET 真正進(jìn)入了 Linux 部署的主流陣營。我身邊好幾個(gè)開源項(xiàng)目包括之前折騰過的那個(gè)“跨平臺(tái)音樂管理系統(tǒng) v2.0 源碼”后端就是用 ASP.NET Core 寫的跑在 Linux 小主機(jī)上用 Nginx 反代配合 SQLite 存儲(chǔ)穩(wěn)定性和部署體驗(yàn)都很好。放到五年前這種組合是不可能想象的。所以為什么說歷史枷鎖被拆掉了因?yàn)閺倪\(yùn)行時(shí)到基礎(chǔ)庫再到 Web 服務(wù)器三層全面換血。底層不再是 Windows 系統(tǒng)調(diào)用的“附庸”服務(wù)器層也不再被 IIS 獨(dú)占。剩下的第三方庫生態(tài)中真正卡你跨平臺(tái)的已經(jīng)不多大多是能用替代方案繞過去的。5. 給老項(xiàng)目遷移者的三條大實(shí)話最后不做什么宏大總結(jié)就說我在實(shí)際折騰遷移過程中最真實(shí)的幾條感受也許比抽象地講歷史更有用。第一條不要指望自動(dòng)化工具“一鍵遷移”。就算用了 .NET Upgrade Assistant老項(xiàng)目里的 Windows 專屬依賴還是得自己一個(gè)個(gè)決定是替換、抽象還是放棄。特別是System.Web相關(guān)的代碼越早重寫越省心留到后面只會(huì)更痛。第二條盡量把跨平臺(tái)邊界畫在“項(xiàng)目依賴”而不是“代碼技巧”上。我見過很多項(xiàng)目寫著寫著就從 NuGet 里引了Microsoft.Win32.Registry原因只是某臺(tái) Windows 機(jī)器上原來用了注冊(cè)表做配置。到了 Linux這個(gè)依賴就得換。最好的做法是開始設(shè)計(jì)時(shí)就明確哪些功能是 Windows-only用接口隔離出來別讓它混進(jìn)核心業(yè)務(wù)邏輯。第三條體驗(yàn)一次 Linux 容器部署比讀十篇文章都管用。拿一個(gè) ASP.NET Core 的空項(xiàng)目放到 Docker 的mcr.microsoft.com/dotnet/aspnet鏡像里跑起來再掛個(gè) Nginx 反代你會(huì)真正體會(huì)到跨平臺(tái)的紅利在哪。容器化加上自包含發(fā)布之后.NET 應(yīng)用和 Go、Node 的應(yīng)用已經(jīng)沒有本質(zhì)區(qū)別構(gòu)建一次到處運(yùn)行。我個(gè)人反而很喜歡這段歷史。它不是“微軟突然就跨平臺(tái)了”的爽文而是先被 Windows 綁了十幾年被 Mono 撬開一道縫被云計(jì)算逼到墻角最后才靠拆掉原有架構(gòu)換來的自由。下篇我會(huì)繼續(xù)聊聊真正的“自由之路”CoreCLR 從零開始的設(shè)計(jì)取舍以及跨平臺(tái)之后 .NET 生態(tài)失去了什么、得到了什么。如果你恰好也在糾結(jié)老項(xiàng)目的去處歡迎先把歷史看懂再動(dòng)手。