﻿# 分享幾個發行 .NET 應用程式的奇技淫巧

無論你是開發 Console 主控台應用程式，或是 ASP․NET Core 網頁應用程式，最終都會需要發行部署，而一個簡單的 `dotnet publish` 卻潛藏著許多不為人知的用法。今天這篇文章我打算來梳理一下我過往曾經用過的發行技巧，做個詳細的用法整理。

![publish](https://stwillblogassets.blob.core.windows.net/files/images/external/stwillblogassets.blob.core.windows.net/4a1ad3723d6df2de445b-27a4595c-cebb-4f16-bc3c-d22809085fd4.webp)

### 準備範例專案

```bash
dotnet new console -n ConsoleApp1
cd ConsoleApp1
```

### 基本發行方法

-   基本用法
    
    基本上透過以下命令就可以發行一個可執行檔：
    
    ```bash
    dotnet publish
    ```
    
    從輸出訊息可以看出，你的預設 Configuration 為 `Debug` 組態，而目標框架為 `net8.0`：
    
    ```
    Microsoft (R) Build Engine version 17.2.2+038f9bae9 for .NET
    Copyright (C) Microsoft Corporation. All rights reserved.
    
      Determining projects to restore...
      All projects are up-to-date for restore.
      ConsoleApp1 -> G:\Projects\ConsoleApp1\bin\Debug\net8.0\ConsoleApp1.dll
      ConsoleApp1 -> G:\Projects\ConsoleApp1\bin\Debug\net8.0\publish\
    ```
    
    > 注意: 發行過程同時也包含了還原套件與專案建置過程。
    
-   不要顯示 Logo 資訊
    
    你可以透過 `--nologo` 參數，將 Microsoft 的產品版本宣告從輸出中移除：
    
    ```bash
    dotnet publish --nologo
    ```
    
    輸出訊息：
    
    ```
      Determining projects to restore...
      All projects are up-to-date for restore.
      ConsoleApp1 -> G:\Projects\ConsoleApp1\bin\Debug\net8.0\ConsoleApp1.dll
      ConsoleApp1 -> G:\Projects\ConsoleApp1\bin\Debug\net8.0\publish\
    ```
    
-   不要建置專案
    
    如果你的專案已經先透過 `dotnet build` 建置過 (例如 CI Pipelines 已經先跑過)，你可以透過 `--no-build` 參數，跳過建置作業，加快發行速度！
    
    ```bash
    dotnet publish --nologo --no-build
    ```
    
    輸出訊息：
    
    ```
      ConsoleApp1 -> G:\Projects\ConsoleApp1\bin\Debug\net8.0\publish\
    ```
    
-   顯示詳細建置記錄
    
    我們在執行 `dotnet publish` 的時候，你知道你是發行 Solution 還是 Project 嗎？想看到建置的細節，就要加上 `-v` 或 `--verbosity` 參數。其參數值總共有 5 種，分別是：
    
    -   `q` 或 `quiet` 完全不輸出訊息
        
    -   `m` 或 `minimal` 輸出最小訊息 (預設值)
        
    -   `n` 或 `normal` 輸出基本訊息
        
    -   `d` 或 `detailed` 輸出詳細的訊息
        
    -   `diag` 或 `diagnostic` 輸出超級詳細的訊息
        
        ```bash
        dotnet publish --nologo --no-build -v n
        ```
        
        輸出訊息：
        
        ```
        Build started 2023/8/24 下午 01:11:31.
            1>Project "G:\Projects\ConsoleApp1\ConsoleApp1.csproj" on node 1 (Publish target(s)).
            1>_CopyResolvedFilesToPublishPreserveNewest:
              Skipping target "_CopyResolvedFilesToPublishPreserveNewest" because all output files are up-to-date
              with respect to the input files.
              Publish:
                ConsoleApp1 -> G:\Projects\ConsoleApp1\bin\Debug\net8.0\publish\
            1>Done Building Project "G:\Projects\ConsoleApp1\ConsoleApp1.csproj" (Publish target(s)).
        
        Build succeeded.
            0 Warning(s)
            0 Error(s)
        
        Time Elapsed 00:00:01.00
        ```
        

### 套用組態設定

-   套用 `Release` 或 `Debug` 組態
    
    你可以透過 `-c` 參數加入組態名稱(Configuration Name)：
    
    ```bash
    dotnet publish -c Release --nologo
    ```
    
    從輸出訊息可以看出，你指定了 `Release` 組態：
    
    ```
      Determining projects to restore...
      All projects are up-to-date for restore.
      ConsoleApp1 -> G:\Projects\ConsoleApp1\bin\Release\net8.0\ConsoleApp1.dll
      ConsoleApp1 -> G:\Projects\ConsoleApp1\bin\Release\net8.0\publish\
    ```
    
-   套用自訂的組態名稱
    
    如果你的專案有 `*.sln` 方案檔，裡面會定義方案可用的組態設定，你可以用以下命令建立方案檔：
    
    ```bash
    dotnet new sln
    dotnet sln ConsoleApp1.sln add ConsoleApp1.csproj
    ```
    
    建立方案檔之後，裡面就可以看到一個 `GlobalSection` 全域區段的 `SolutionConfigurationPlatforms` 設定，裡面預設只有定義 `Debug` 與 `Release` 組態名稱：
    
    ```sln
    Microsoft Visual Studio Solution File, Format Version 12.00
    # Visual Studio Version 17
    VisualStudioVersion = 17.0.31903.59
    MinimumVisualStudioVersion = 10.0.40219.1
    Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleApp1", "ConsoleApp1.csproj", "{F2538AF2-D25E-4FA2-9D3C-DC6EF10D2BE6}"
    EndProject
    Global
      GlobalSection(SolutionConfigurationPlatforms) = preSolution
        Debug|Any CPU = Debug|Any CPU
        Release|Any CPU = Release|Any CPU
      EndGlobalSection
      GlobalSection(SolutionProperties) = preSolution
        HideSolutionNode = FALSE
      EndGlobalSection
      GlobalSection(ProjectConfigurationPlatforms) = postSolution
        {F2538AF2-D25E-4FA2-9D3C-DC6EF10D2BE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
        {F2538AF2-D25E-4FA2-9D3C-DC6EF10D2BE6}.Debug|Any CPU.Build.0 = Debug|Any CPU
        {F2538AF2-D25E-4FA2-9D3C-DC6EF10D2BE6}.Release|Any CPU.ActiveCfg = Release|Any CPU
        {F2538AF2-D25E-4FA2-9D3C-DC6EF10D2BE6}.Release|Any CPU.Build.0 = Release|Any CPU
      EndGlobalSection
    EndGlobal
    ```
    
    如果你建置或發行的對象是 `*.sln` 方案檔的話，使用了定義以外的組態名稱就會出現錯誤，我以 `Staging` 這個名稱為例：
    
    ```bash
    dotnet publish -c Staging --nologo
    ```
    
    從輸出的錯誤訊息就可以看出有個 `MSB4126` 的建置錯誤：
    
    ```
    G:\Projects\ConsoleApp1\ConsoleApp1.sln.metaproj : error MSB4126: The specified solution configuration "Staging|Any CPU" is invalid. Please specify a valid solution configuration using the Configuration and Platform properties (e.g. MSBuild.exe Solution.sln /p:Configuration=Debug /p:Platform="Any CPU") or leave those properties blank to use the default solution configuration. [G:\Projects\ConsoleApp1\ConsoleApp1.sln]
    ```
    
    如果建置發行的目錄沒有 `*.sln` 或是直接建置發行 `*.csproj` 的話，就沒有這個限制，例如：
    
    ```bash
    dotnet publish ConsoleApp1.csproj -c Staging --nologo
    ```
    
    輸出訊息：
    
    ```
      Determining projects to restore...
      All projects are up-to-date for restore.
      ConsoleApp1 -> G:\Projects\ConsoleApp1\bin\Staging\net8.0\ConsoleApp1.dll
      ConsoleApp1 -> G:\Projects\ConsoleApp1\bin\Staging\net8.0\publish\
    ```
    
-   組態的用法
    
    如果你有一段 C# 程式碼只需要在**特定組態**下執行，那麼你可以透過[條件式編譯](https://learn.microsoft.com/zh-tw/dotnet/csharp/language-reference/preprocessor-directives?WT.mc_id=DT-MVP-4015686#conditional-compilation)語法來定義，如下範例：
    
    ```csharp
    #if DEBUG
        Console.WriteLine("Debug build");
    #elif STAGING
        Console.WriteLine("Staging build");
    #else
        Console.WriteLine("Release build");
    #endif
    ```
    
    > 請注意：程式碼中的組態名稱「一定」要寫成「大寫英文」喔！
    

### 框架相依部署 (Framework-dependent Deployment)

框架相依部署 (FDD) 意味著你要在部署的目標電腦先安裝 .NET Runtime 才能執行你的應用程式。

事實上 FDD 還有區分兩種：

1.  FDE (Framework-dependent Executable)
    
    含有可執行檔的應用程式，如果你在 Windows 平台執行 `dotnet publish` 命令，預設就是採用這種方式進行部署，預設會產生一個 `*.exe` 可執行檔。以本文的範例來說，就會出現一個 `ConsoleApp1.exe` 執行檔。
    
    你可以透過以下命令來發行指定 Linux 平台的可執行檔：
    
    ```bash
    dotnet publish -c Release -r linux-x64 --no-self-contained
    ```
    
    > 其中的 `linux-x64` 我們稱為 RID (Runtime Identifier)，常見的 RID 有 `win-x64`, `linux-x64`, `osx-x64` 這三個，完整的 RID 清單請見 [.NET RID Catalog](https://learn.microsoft.com/en-us/dotnet/core/rid-catalog?WT.mc_id=DT-MVP-4015686)
    
    如果你想在 Windows (x64) 發佈一個單一執行檔的 FDE 部署，可以加入 `-p:DebugType=embedded` 參數，將偵錯符號內嵌到執行檔中，範例如下：
    
    ```batch
    dotnet publish -c Release -r win-x64 --no-self-contained -p:DebugType=embedded
    ```
    
2.  FDD (Framework-dependent Deployment)
    
    沒有可執行檔的應用程式，部署的檔案主要以 `*.dll` 為主，需要透過 .NET Runtime 的 AppHost (也就是 `dotnet.exe` 程式) 來啟動應用程式，例如：
    
    ```bash
    dotnet ConsoleApp1.dll
    ```
    
    事實上，這個 `ConsoleApp1.exe` 執行檔只是一個「啟動器」，它會去呼叫 `dotnet.exe` 來執行 `ConsoleApp1.dll` 檔案，因此對容器化應用程式來說，這個 `ConsoleApp1.exe` 著實有點雞肋，所以我們在部署的時候可以加入 `-p:UseAppHost=false` 避免產生這個可執行檔。
    
    ```bash
    dotnet publish -c Release -p:UseAppHost=false
    ```
    

### 自封式部署 (Self-contained Deployment)

自封式部署 (SCD) 意味著你的應用程式會包含 .NET Runtime，所以不需要在部署的目標電腦安裝 .NET Runtime 就可以執行，但是會導致輸出的執行檔較大。

-   發行一個內含 .NET Runtime 的部署
    
    ```bash
    dotnet publish -c Release --nologo --self-contained -r win-x64
    ```
    
    輸出訊息：
    
    ```
      Determining projects to restore...
      All projects are up-to-date for restore.
      ConsoleApp1 -> G:\Projects\ConsoleApp1\bin\Release\net8.0\win-x64\ConsoleApp1.dll
      ConsoleApp1 -> G:\Projects\ConsoleApp1\bin\Release\net8.0\win-x64\publish\
    ```
    
    > 常見的 RID 有 `win-x64`, `linux-x64`, `osx-x64` 這三個，完整的 RID 清單請見 [.NET RID Catalog](https://learn.microsoft.com/en-us/dotnet/core/rid-catalog?WT.mc_id=DT-MVP-4015686)
    
    這樣的部署方式會產生大量的檔案在輸出目錄，而且總檔案大小會較大！
    
-   將自封式部署發行成單一檔案
    
    加上 `-p:PublishSingleFile=true` 參數，就可以把所有相依的 `*.dll` 或 `*.json` 檔案全部打包進 `*.exe` 主要執行檔中：
    
    ```bash
    dotnet publish -c Release --nologo --self-contained -r win-x64 -p:PublishSingleFile=true
    ```
    
    > 自行在 `*.csproj` 加入一個 `PublishSingleFile` 屬性並將屬性值設定為 `true` 也一樣有相同效果，請見 [Sample project file](https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/overview?tabs=cli&WT.mc_id=DT-MVP-4015686#sample-project-file) 範例！
    
    輸出訊息：
    
    ```
      Determining projects to restore...
      All projects are up-to-date for restore.
      ConsoleApp1 -> G:\Projects\ConsoleApp1\bin\Release\net8.0\win-x64\ConsoleApp1.dll
      ConsoleApp1 -> G:\Projects\ConsoleApp1\bin\Release\net8.0\win-x64\publish\
    ```
    
    我們列出 `bin\Release\net8.0\win-x64\publish\` 資料夾的內容，你會發現檔案只剩下兩個：
    
    ```
     Directory of G:\Projects\ConsoleApp1\bin\Release\net8.0\win-x64\publish
    
    2023/08/24  下午 02:15    <DIR>          .
    2023/08/24  下午 02:15    <DIR>          ..
    2023/08/24  下午 02:15        63,704,638 ConsoleApp1.exe
    2023/08/24  下午 02:15            10,256 ConsoleApp1.pdb
    ```
    
-   將自封式部署發行成「真正的」單一檔案
    
    如果你連 `*.pdb` 都不想看到的話，也可以考慮將所有的 `*.pdb` 偵錯符號檔內嵌到 `*.dll` 或 `*.exe` 之中，只要加入 `-p:DebugType=embedded` 參數即可！範例如下：
    
    ```bash
    dotnet publish -c Release --nologo --self-contained -r win-x64 -p:PublishSingleFile=true -p:DebugType=embedded
    ```
    
    如果你不想輸出 `*.pdb` 也不想內嵌 `*.pdb` 的話，可以這樣下命令：
    
    ```bash
    dotnet publish -c Release --nologo --self-contained -r win-x64 -p:PublishSingleFile=true -p:DebugType=none
    ```
    
    相關設定可參考 [C# Compiler Options that control code generation](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-options/code-generation?WT.mc_id=DT-MVP-4015686#debugtype) 文件。
    
    > 注意：在 .NET Core 3.1 之前有個 `-p:IncludeSymbolsInSingleFile=true` 參數已經失效，請用本文介紹的方式取代。
    
    另外，如果你希望特定 `*.dll` 檔案不要封裝到單一檔案中的話，必須在 `*.csproj` 專案檔加入以下設定 (參考 [Exclude files from being embedded](https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/overview?WT.mc_id=DT-MVP-4015686&tabs=cli#exclude-files-from-being-embedded) 文件)：
    
    ```xml
    <ItemGroup>
      <Content Update="Plugin.dll">
        <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
      </Content>
    </ItemGroup>
    ```
    
-   將自封式部署發行成「真真真正的」單一檔案
    
    你在發行的時候所加入的 `--self-contained` 參數，只會將 .NET 的 Managed DLL 合併在一起，若建置過程包含 Native libraries (原生函式庫) 檔案的話，這些檔案就會跟輸出的執行檔放在一起，那就不是真正的「單一檔案」發行了！
    
    為了要將這些原生檔案加入到單一執行檔中，你可以額外加入 `-p:IncludeNativeLibrariesForSelfExtract=true` 參數：
    
    ```bash
    dotnet publish -c Release --nologo --self-contained -r win-x64 -p:PublishSingleFile=true -p:DebugType=embedded -p:IncludeNativeLibrariesForSelfExtract=true
    ```
    
    另外還有個 `-p:IncludeAllContentForSelfExtract=true` 參數可以將所有發行的「內容」檔案都加入到執行檔中，執行時所有檔案都會被解壓縮出來，整個應用程式會被解壓縮到一個暫存目錄中執行。這個暫存目錄 Windows 的預設路徑為 `%TEMP%/.net`，而 Linux 與 macOS 的預設路徑為 `$HOME/.net`，你也可以定義 `DOTNET_BUNDLE_EXTRACT_BASE_DIR` 環境變數來指定解壓縮的資料夾路徑。
    
    如果你的應用程式會部署到 Linux 的 `systemd` 執行成背景服務的話，請記得務必要設定 `DOTNET_BUNDLE_EXTRACT_BASE_DIR` 環境變數，否則應用程式會找不到 `$HOME/.net` 路徑來解壓縮。設定範例如下：
    
    ```ini
    [Service]
    Environment="DOTNET_BUNDLE_EXTRACT_BASE_DIR=%h/.net"
    ```
    
    > 這兩個參數會改變 [AppContext.BaseDirectory](https://learn.microsoft.com/en-us/dotnet/api/system.appcontext.basedirectory?view=net-7.0&WT.mc_id=DT-MVP-4015686#system-appcontext-basedirectory) 的路徑，會變成是「解壓縮後」的目錄喔，這點要注意！
    
-   將自封式部署發行成「真真真正的」單一檔案，順便刪減沒用到的 IL 指令碼
    
    你可以透過 `-p:PublishTrimmed=true` 將沒用到的 IL 指令碼給刪減掉，大幅降低最後輸出的執行檔大小：
    
    ```bash
    dotnet publish -c Release --nologo --self-contained -r win-x64 -p:PublishSingleFile=true -p:DebugType=embedded -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=true
    ```
    
    結果的執行檔真的蠻小的：
    
    ```
     Volume in drive G is Temporary
     Volume Serial Number is FA93-F4AA
    
     Directory of G:\Projects\ConsoleApp1\bin\Release\net8.0\win-x64\publish
    
    2023/08/24  下午 02:42    <DIR>          .
    2023/08/24  下午 02:42    <DIR>          ..
    2023/08/24  下午 02:59        11,435,338 ConsoleApp1.exe
    ```
    
    > 刪減 IL 指令碼可能會導致部分用到 Reflection 機制的程式碼失效，使用這個參數前請務必測試過才能放心使用。另外，使用了 `-p:PublishTrimmed=true` 參數後，就不能同時指定 `--no-build` 參數。
    
-   將自封式部署發行成「真真真正的」單一檔案，刪減沒用到的 IL 指令碼，還順便壓縮執行檔
    
    由於自封式部署的輸出大小較大，為了要能降低執行檔的大小，你還可以加上 `-p:EnableCompressionInSingleFile=true` 參數：
    
    ```bash
    dotnet publish -c Release --nologo --self-contained -r win-x64 -p:PublishSingleFile=true -p:DebugType=embedded -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishTrimmed=true -p:EnableCompressionInSingleFile=true
    ```
    
    > 壓縮之後的執行檔，會在第一次執行時自動解壓縮到 `%TEMP%\.net` 目錄下，所以啟動速度會稍微慢一點。
    

### 相關連結

-   [Single-file deployment - .NET | Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/overview?tabs=cli&WT.mc_id=DT-MVP-4015686#exclude-files-from-being-embedded)
-   [Support Single-File Apps in .NET 5 · Issue #36590 · dotnet/runtime](https://github.com/dotnet/runtime/issues/36590)
-   [designs/accepted/2020/single-file/design.md at main · dotnet/designs](https://github.com/dotnet/designs/blob/main/accepted/2020/single-file/design.md)
-   [C# 前置處理器指示詞 | Microsoft Learn](https://learn.microsoft.com/zh-tw/dotnet/csharp/language-reference/preprocessor-directives?WT.mc_id=DT-MVP-4015686#conditional-compilation)
