理解 C# 项目 csproj 文件格式的本质和编译流程
寫了這么多個 C# 項目,是否對項目文件 csproj 有一些了解呢?Visual Studio 是怎么讓 csproj 中的內容正確顯示出來的呢?更深入的,我能夠自己擴展 csproj 的功能嗎?
本文將直接從 csproj 文件格式的本質來看以上這些問題。
閱讀本文,你將:
可以通讀 csproj 文件,并說出其中每一行的含義
可以手工修改 csproj 文件,以實現你希望達到的高級功能(更高級的,可以開始寫個工具自動完成這樣的工作了)
理解新舊 csproj 文件的差異,不至于寫工具解析和修改 csproj 文件的時候出現不兼容的錯誤
本文內容
csproj 里面是什么?
總覽 csproj 文件
了解 csproj 中的各個部件的作用
PropertyGroup
ItemGroup
Import
Target
Project
編譯器是如何將這些零散的部件組織起來的?
新舊 csproj 在編譯過程上有什么差異?
更多資料
csproj 里面是什么?
總覽 csproj 文件
相信你一定見過傳統的 csproj 文件格式。就算你幾乎從來沒主動去看過里面的內容,在版本管理工具中解沖突時也在里面修改過內容。
不管你是新手還是老手,一定都會覺得這么長這么復雜的文件一定不是給人類閱讀的。你說的是對的!傳統 csproj 文件中有大量的重復或者相似內容,只為 msbuild 和 Visual Studio 能夠識別整個項目的屬性和結構,以便正確編譯項目。
不過,既然這篇文章的目標是理解 csproj 文件格式的本質,那我當然不會把這么復雜的文件內容直接給你去閱讀。
我已經將整個文件結構進行了極度簡化,然后用思維導圖進行了分割。總結成了下圖,如果先不關注文件的細節,是不是更容易看懂了呢?
如果你此前也閱讀過我的其他博客,會發現我一直在試圖推薦使用新的 csproj 格式:
將 WPF、UWP 以及其他各種類型的舊樣式的 csproj 文件遷移成新樣式的 csproj 文件
讓一個 csproj 項目指定多個開發框架
那么新格式和舊格式究竟有哪些不同使得新的格式如此簡潔?
于是,我將新的 csproj 文件結構也進行簡化,用思維導圖進行了分割。總結成了下圖:
比較兩個思維導圖之后,是不是發現其實兩者本是相同的格式。如果忽略我在文字顏色上做的標記,其實兩者的差異幾乎只在文件開頭是否有一個 xml 文件標記(<?xml version="1.0" encoding="utf-8"?>)。我在文字顏色上的標記代表著這部分的部件是否是可選的,白色代表必須,灰色代表可選;而更接近背景色的灰色代表一般情況下都是不需要的。
我把兩個思維導圖放到一起方便比較:
會發現,傳統格式中?xml 聲明、Project 節點、Import (props)、PropertyGroup、ItemGroup、Import (targets)?都是必要的,而新格式中只有?Project 節點?和?PropertyGroup?是必要的。
是什么導致了這樣的差異?在了解 csproj 文件中各個部件的作用之前,這似乎很難回答。
了解 csproj 中的各個部件的作用
xml 聲明部分完全沒有在此解釋的必要了,為兼容性提供了方便,詳見:XML - Wikipedia。
接下來,我們不會依照部件出現的順序安排描述的順序,而是按照關注程度排序。
PropertyGroup
PropertyGroup?是用來存放屬性的地方,這與它的名字非常契合。那么里面放什么屬性呢?答案是——什么都能放!
在這里寫屬性就像在代碼中定義屬性或變量一樣,只要寫了,就會生成一個指定名稱的屬性。
比如,我們寫:
<PropertyGroup><Foo>walterlv is a 逗比</Foo><PropertyGroup>那么,就會生成一個?Foo?屬性,值為字符串?walterlv is a 逗比。至于這個屬性有什么用,那就不歸這里管了。
這些屬性的含義完全是由外部來決定的,例如在舊的 csproj 格式中,編譯過程中會使用?TargetFrameworkVersion?屬性,以確定編譯應該使用的 .NET Framework 目標框架的版本(是 v4.5 還是 v4.7)。在新的 csproj 格式中,編譯過程會使用?TargetFrameworks?屬性來決定編譯應該使用的目標框架(是 net47 還是 netstandard2.0)。具體是編譯過程中的哪個環節哪個組件使用了此屬性,我們后面會說。
從這個角度來說,如果你沒有任何地方用到了你定義的屬性,那為什么還要定義它呢?是的——這只是浪費。
PropertyGroup?可以定義很多個,里面都可以同等地放屬性。至于為什么會定義多個,原因無外乎兩個:
為了可讀性——將一組相關的屬性放在一起,便于閱讀和理解意圖(舊的 csproj 談不上什么可讀性)
為了加條件——有的屬性在 Debug 和 Release 下不一樣(例如條件編譯符?DefineConstants)
額外說一下,Debug?和?Release?這兩個值其實是在某處一個名為?Configuration?的屬性定義的,它們其實只是普通的字符串而已,沒什么特殊的意義,只是有很多的?PropertyGroup?加上了?Debug?Release?的判斷條件才使得不同的?Configuration?具有不同的其他屬性,最終表現為編譯后的巨大差異。由于?Configuration?屬性可以放任意字符串,所以甚至可以定義一個非?Debug?和?Release?的配置(例如用于性能專項測試)也是可以的。
ItemGroup
ItemGroup?是用來指定集合的地方,這與它的名字非常契合。那么這集合里面放什么項呢?答案是——什么都能放!
是不是覺得這句話跟前面的?PropertyGroup?句式一模一樣?是的——就是一模一樣!csproj 中的兩個大頭都這樣不帶語義,幾乎可以說明 csproj 文件是不包含語義的,它能夠用來做什么事情純屬由其他模塊來指定;這為 csproj 文件強大的擴展性提供了格式基礎。
既然什么都能放,那我們放這些吧:
<ItemGroup><Foo>walterlv is a 逗比</Foo><Foo>walterlv is a 天才</Foo><Foo>天才向左,逗比向右</Foo><Foo>逗比屬性額外加成</Foo></ItemGroup>于是我們就有 4 個類型為?Foo?的項了,至于這 4 個?Foo?項有什么作用,那就不歸這里管了。
這些項的含義與?PropertyGroup?一樣也是由外部來決定。具體是哪個外部,我們稍后會說。但是我們依然有一些常見的項可以先介紹介紹:
Reference?引用某個程序集
PackageReference?引用某個 NuGet 包
ProjectReference?引用某個項目
Compile?常規的 C# 編譯
None?沒啥特別的編譯選項,就為了執行一些通用的操作(或者是只是為了在 Visual Studio 列表中能夠有一個顯示)
Folder?一個空的文件夾,也沒啥用(不過標了這個文件夾,Visual Studio 中就能有一個文件夾的顯式,即便實際上這個文件夾可能不存在)
ItemGroup?也可以放很多組,一樣是為了提升可讀性或者增加條件。
Import
你應該注意到在前面的思維導圖中,無論是新 csproj 還是舊 csproj 文件,我都寫了兩個?Import?節點。其實它們本質上是完全一樣的,只不過在含義上有不同。前面我們了解到 csproj 文件致力于脫離語義,所以分開兩個地方寫幾乎只是為了可讀性考慮。
那么前面那個?Import?和后面的?Import?在含義上有何區別?思維導圖的括號中我已說明了含義。前面是為了導入屬性(props),后面是為了導入?Targets。屬性就是前面?PropertyGroup?中說的那些屬性和?ItemGroup?里說的那些項;而?Targets?是新東西,這才是真正用來定義編譯流程的關鍵,由于?Targets?是所有節點里面最復雜的部分,所以我們放到最后再說。
那么,被我們?Import?進來的那些文件是什么呢?用兩種擴展名,定義屬性的那一種是?.props,定義行為的那一種是?.targets。
這兩種文件除了含義不同以外,內容的格式都是完全一樣的——而且——就是 csproj 文件的那種格式!沒錯,也包含?Project、Import、PropertyGroup、ItemGroup、Targets。只不過,相比于對完整性有要求的 csproj 文件來說,這里可以省略更多的節點。由于有?Import?的存在,所以一層一層地嵌套?props?或者?targets?都是可能的。
說了這么多,讓我們來看其中兩個 .props 文件吧。
先看看舊格式 csproj 文件中第一行一定會?Import?的那個?Microsoft.Common.props。
<!-- 文件太長,做了大量刪減 --><Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"><PropertyGroup><ImportByWildcardBeforeMicrosoftCommonProps Condition="'$(ImportByWildcardBeforeMicrosoftCommonProps)' == ''">true</ImportByWildcardBeforeMicrosoftCommonProps><ImportByWildcardAfterMicrosoftCommonProps Condition="'$(ImportByWildcardAfterMicrosoftCommonProps)' == ''">true</ImportByWildcardAfterMicrosoftCommonProps><ImportUserLocationsByWildcardBeforeMicrosoftCommonProps Condition="'$(ImportUserLocationsByWildcardBeforeMicrosoftCommonProps)' == ''">true</ImportUserLocationsByWildcardBeforeMicrosoftCommonProps><ImportUserLocationsByWildcardAfterMicrosoftCommonProps Condition="'$(ImportUserLocationsByWildcardAfterMicrosoftCommonProps)' == ''">true</ImportUserLocationsByWildcardAfterMicrosoftCommonProps><ImportDirectoryBuildProps Condition="'$(ImportDirectoryBuildProps)' == ''">true</ImportDirectoryBuildProps></PropertyGroup></Project><!-- 文件太長,做了大量刪減 -->文件太長,做了大量刪減,但也可以看到文件格式與 csproj 幾乎是一樣的。此文件中,根據其他屬性的值有條件地定義了另一些屬性。
再看看另一個 MSTest 單元測試項目中被隱式?Import?進 csproj 文件中的 .props 文件。(所謂隱式地?Import,只不過是被間接地引入,在 csproj 文件中看不到這個文件名而已。至于如何間接引入,因為涉及到?Targets,所以后面一起說明。)
<?xml version="1.0" encoding="utf-8"?><Project ToolsVersion="12.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"><ItemGroup><Content Include="$(MSBuildThisFileDirectory)..\_common\Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.dll"><Link>Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.dll</Link><CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory><Visible>False</Visible></Content><Content Include="$(MSBuildThisFileDirectory)..\_common\Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Interface.dll"><Link>Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Interface.dll</Link><CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory><Visible>False</Visible></Content><Content Include="$(MSBuildThisFileDirectory)..\_common\Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.dll"><Link>Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.dll</Link><CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory><Visible>False</Visible></Content></ItemGroup></Project>此文件中將三個 dll 文件從 MSTest 的 NuGet 包中以鏈接的形式包含到項目中,并且此文件在 Visual Studio 的解決方案列表中不可見。
可以看出,引入的 props 文件可以實現幾乎與 csproj 文件中一樣的功能。
那么,既然 csproj 文件中可以完全實現這樣的功能,為何還要單獨用?props?文件來存放呢?原因顯而易見了——為了在多個項目中使用,一處更新,到處生效。所以有沒有覺得很好玩——如果把版本號單獨放到 props 文件中,就能做到一處更新版本號,到處更新版本號啦!
Target
終于開始說 Target 了。為什么會這么期待呢?因為前面埋下的各種伏筆幾乎都要在這一節點得到解釋了。
一般來說,Target?節點寫在 csproj 文件的末尾,但這個并不是強制的。Targets 是一種非常強大的功能擴展方式,支持 msbuild 預定義的一些指令,支持命令行,甚至支持使用 C# 直接編寫(當然編譯成 dll 會更方便些),還支持這些的排列組合和順序安排。而我們實質上的編譯過程便全部由這些 Targets 來完成。我們甚至可以直接說——編譯過程就是靠這些?Target?的組合來完成的。
如果你希望全面了解 Targets,推薦直接閱讀微軟的官方文檔?MSBuild Targets,而本文只會對其進行一些簡單的概述(我即將用另一篇博客來詳細講解,不然這篇就太長了)。
不過,為了簡單地理解?Target,我依然需要借用官方文檔的例子作為開頭。
<Target Name="Construct"><Csc Sources="@(Compile)" /></Target>這份代碼定義了一個名為?Construct?的?Target,這是隨意取的一個名字,并不重要——但是編譯過程中會執行這個?Target。在這個?Target?內部,使用了一個 msbuild 自帶的名為?Csc?的?Task。這里我們再次引入了一個新的概念?Task。而?Task?是?Target內部真正完成邏輯性任務的核心;或者說?Target?其實只是一種容器,本身并不包含編譯邏輯,但它的內部可以存放?Task?來實現編譯邏輯。一個?Target?內可以放多個?Task,不止如此,還能放?PropertyGroup?和?ItemGroup,不過這是僅在編譯期生效的屬性和項了。
@(Compile)?是?ItemGroup?中所有?Compile?類型節點的集合。還記得我們在?ItemGroup?小節時說到每一種?Item?的含義由外部定義嗎?是的,就是在這里定義的!本身并沒有什么含義,但它們作為參數傳入到了具體的?Task?之后便有了此?Task?指定的含義。
于是?<Target Name="Construct"><Csc Sources="@(Compile)" /></Target>?的含義便是調用 msbuild 內置的 C# 編譯器編譯所有?Compile?類型的項。
如果后面定義了一個跟此名稱一樣的?Target,那么后一個?Target?就會覆蓋前一個?Target,導致前一個?Target?失效。
再次回到傳統的 csproj 文件上來,每一個傳統格式的 csproj 都有這樣一行:
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
而引入的這份?.targets?文件便包含了 msbuild 定義的各種核心編譯任務。只要引入了這個?.targets?文件,便能使用 msbuild 自帶的編譯任務完成絕大多數項目的編譯。你可以自己去查看此文件中的內容,相信有以上?Target?的簡單介紹,應該能大致理解其完成編譯的流程。這是我的地址:C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\MSBuild\15.0\Bin\Microsoft.CSharp.targets。
Project
所有的 csproj 文件都是以?Project?節點為根節點。既然是根節點為何我會在最后才說?Project?呢?因為這可是一個大懸念啊!本文一開始就描述了新舊兩款 csproj 文件格式的差異,你也能從我的多篇博客中感受到新格式帶來的各種好處;而簡潔便是新格式中最大的好處之一。它是怎么做到簡潔的呢?
就靠?Project?節點了。
注意到新格式中?Project?節點有?Sdk?屬性嗎?因為有此屬性的存在,csproj 文件才能如此簡潔。因為——所謂 Sdk,其實是一大波?.targets?文件的集合。它幫我們導入了公共的屬性、公共的編譯任務,還幫我們自動將項目文件夾下所有的?**\*.cs?文件都作為?ItemGroup?的項引入進來。
如果你希望看看?Microsoft.NET.Sdk?都引入了哪些文件,可以去本機安裝的 msbuild 或 dotnet 的目錄下查看。當我使用 msbuild 編譯時,我的地址:C:\Program Files\dotnet\sdk\2.1.200\Sdks\Microsoft.NET.Sdk\build\。比如你可以從此文件夾里的?Microsoft.NET.GenerateAssemblyInfo.targets?文件中發現?AssemblyInfo.cs?文件是如何自動生成及生效的。
編譯器是如何將這些零散的部件組織起來的?
這里說的編譯器幾乎只指 msbuild 和 Roslyn,前者基于 .NET Framework,后者基于 .NET Core。不過,它們在處理我們的項目文件時的行為大多是一致的——至少對于通常項目來說如此。
我們前一部分介紹每個部件的時候,已經簡單說了其組織方式,這里我們進行一個回顧和總結。
當 Visual Studio 打開項目時,它會解析里面所有的?Import?節點,確認應該引入的 .props 和 .targets 文件都引入了。隨后根據?PropertyGroup?里面設置的屬性正確顯示屬性面板中的狀態,根據?ItemGroup?中的項正確顯示解決方案管理器中的引用列表、文件列表。——這只是 Visual Studio 做的事情。
在編譯時,msbuild 或 Roslyn 還會重新做一遍上面的事情——畢竟這兩個才是真正的編譯器,可不是 Visual Studio 的一部分啊。隨后,執行編譯過程。它們會按照?Target?指定的先后順序來安排不同?Target?的執行,當執行完所有的?Target,便完成了編譯過程。
新舊 csproj 在編譯過程上有什么差異?
相信讀完前面兩個部分之后,你應該已經了解到在格式本身上,新舊格式之間其實并沒有什么差異。或者更嚴格來說,差異只有一條——新格式在 Project 上指定了?Sdk。真正造成新舊格式在行為上的差別來源于默認為我們項目?Import?進來的那些 .props 和 .targets 不同。新格式通過?Microsoft.NET.Sdk?為我們導入了更現代化的 .props 和 .targets,而舊格式需要考慮到兼容性壓力,只能引入舊的那些 .targets。
新的?Microsoft.NET.Sdk?以不兼容的方式支持了各種新屬性,例如新的?TargetFrameworks?代替舊的?TargetFrameworkVersion,使得我們的 C# 項目可以脫離 .NET Framework,引入其他各種各樣的目標框架,例如 netstandard2.0、net472、uap10.0 等(可以參考?從以前的項目格式遷移到 VS2017 新項目格式 - 林德熙)了解可以使用那些目標框架。
新的?Microsoft.NET.Sdk?以不兼容的方式原生支持了 NuGet 包管理。也就是說我們可以在不修改 csproj 的情況之下通過 NuGet 包來擴展 csproj 的功能。而舊的格式需要在 csproj 文件的末尾添加如下代碼才可以獲得其中一個 NuGet 包功能的支持:
<Import Project="..\packages\Walterlv.Demo.3.0.0-beta.6\build\Walterlv.Demo.targets" Condition="Exists('..\packages\Walterlv.Demo.3.0.0-beta.6\build\Walterlv.Demo.targets')" /><Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"><PropertyGroup><ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. ?For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText></PropertyGroup><Error Condition="!Exists('..\packages\Walterlv.Demo.3.0.0-beta.6\build\Walterlv.Demo.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Walterlv.Demo.3.0.0-beta.6\build\Walterlv.Demo.targets'))" /></Target>不過好在 NuGet 4.x 以上版本在安裝 NuGet 包時自動為我們在 csproj 中插入了以上代碼。
更多資料
如果你在閱讀本文時還有更多問題,可以閱讀我和朋友的其他相關博客,也可以隨時在下方向我留言。如果沒有特別原因,我都是在一天之內進行回復。
項目文件中的已知屬性(知道了這些,就不會隨便在 csproj 中寫死常量了) - 呂毅
讓一個 csproj 項目指定多個開發框架 - 呂毅
從以前的項目格式遷移到 VS2017 新項目格式 - 林德熙
將 WPF、UWP 以及其他各種類型的舊樣式的 csproj 文件遷移成新樣式的 csproj 文件 - 呂毅
自動將 NuGet 包的引用方式從 packages.config 升級為 PackageReference - 呂毅
本文會經常更新,請閱讀原文:?https://walterlv.github.io/post/understand-the-csproj.html,以避免陳舊錯誤知識的誤導,同時有更好的閱讀體驗。
原文地址: https://walterlv.github.io/post/understand-the-csproj.html
.NET社區新聞,深度好文,歡迎訪問公眾號文章匯總 http://www.csharpkit.com
總結
以上是生活随笔為你收集整理的理解 C# 项目 csproj 文件格式的本质和编译流程的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: .NET 图形化开源爬虫Hawk 3发布
- 下一篇: 使用.NET Core与Google O