﻿# 取得 .NET 8 應用程式的版本資訊必須注意的兩三事

我前陣子幫客戶開發一個 Windows Forms 應用程式，使用 .NET 8 來開發，不過在案子的最後一刻，客戶要求要加上一個「版本資訊」的功能，這個功能要顯示目前程式的版本號。這個功能實在是不常寫，沒想到還真的有點地雷，因為跟 .NET Framework 有點不太一樣了。這篇文章就來分享一下我在開發過程中遇到的問題，以及解決方法。

![An abstract, conceptual wide banner illustrating the process of troubleshooting and resolving issues in  NET 8 Windows Forms applications](https://stwillblogassets.blob.core.windows.net/files/images/external/stwillblogassets.blob.core.windows.net/48747423e415853a284a-9efd773b-96a1-453e-88fb-751526df5614.webp)

### 我的第一版實作

首先，我先在 `*.csproj` 加入 `<FileVersion>0.1.0.0</FileVersion>` 屬性：

```xml
<PropertyGroup>
  <OutputType>WinExe</OutputType>
  <TargetFramework>net8.0-windows</TargetFramework>
  <Nullable>enable</Nullable>
  <UseWindowsForms>true</UseWindowsForms>
  <ImplicitUsings>enable</ImplicitUsings>
  <Configurations>Debug;Release;LOCALDEV</Configurations>

  <FileVersion>0.1.0.0</FileVersion>

</PropertyGroup>
```

然後我使用 `Assembly.GetExecutingAssembly().Location` 來取得 DLL 組件的檔案路徑，然後再用 `FileVersionInfo.GetVersionInfo` 來取得版本號。

```csharp
string? GetVersionInfo()
{
    // Get the executing assembly
    Assembly assembly = Assembly.GetExecutingAssembly();

    // Get the location of the assembly
    string location = assembly.Location;

    // Get the FileVersionInfo for the assembly
    FileVersionInfo fileVersionInfo = FileVersionInfo.GetVersionInfo(location);

    // Get the file version
    return fileVersionInfo.FileVersion;
}
```

在交付之前，我當然有測試過，這個方法在我的開發環境上是可以正常運作的。

不過，當我把應用程式發佈給客戶後，程式竟然會掛掉，因為我的發佈方式採用了 `SCD` (Self-Contained Deployment) 部署方法，而且把所有檔案全部都打包成單一檔案：

```bash
dotnet publish -c DEBUG --nologo --self-contained -r win-x64 -p:PublishSingleFile=true -p:DebugType=embedded -p:IncludeNativeLibrariesForSelfExtract=true
```

執行時的錯誤訊息如下：

```
Unhandled exception. System.ArgumentException: The path is empty. (Parameter 'path')
   at System.IO.Path.GetFullPath(String path)
   at System.Diagnostics.FileVersionInfo.GetVersionInfo(String fileName)
```

查了一下問題後才驚覺，原來用了 `-p:PublishSingleFile=true` 發佈為單一檔案時，這個 `Assembly.GetExecutingAssembly().Location` 會得到空字串，所以 `FileVersionInfo.GetVersionInfo` 才會拋出例外。

事實上，我在進行建置/發佈的時候，Console 有出現以下警告，不過我當時沒有留意：

> warning IL3000: 'System.Reflection.Assembly.Location' always returns an empty string for assemblies embedded in a single-file app. If the path to the app directory is needed, consider calling 'System.AppContext.BaseDirectory'.

這段訊息講的蠻清楚的，不過 `System.AppContext.BaseDirectory` 僅包含了「資料夾」路徑，並不包含「執行檔」的路徑，所以我還是無法取得版本號。

### 解決方法

我深入的瞭解了一下，發現有好幾個方法可以取得執行檔的路徑，我頓時陷入了選擇障礙：

1.  `Environment.ProcessPath`
    
    這個靜態屬性是從 .NET 6 開始才有的，可以取得目前正在執行的「執行檔」路徑，不過使用上要小心。
    
2.  `System.Reflection.Assembly.GetEntryAssembly().Location`
    
    當一個應用程式啟動時，這個方法會返回最初啟動應用程式時的那個組件(DLL)，且通常是包含應用程式的 `Main` 函式的那個執行程序。而 `.Location` 屬性會回傳該檔案的路徑。
    
3.  `System.Reflection.Assembly.GetExecutingAssembly().Location`
    
    當一個函式被呼叫時，此方法將返回該函式所在的那個組件(DLL)。
    

由於我的需求是想取得「應用程式」的版本號，所以比較正確的選擇，應該只有以下兩種：

1.  `Environment.ProcessPath`
2.  `System.Reflection.Assembly.GetEntryAssembly().Location`

有趣的事情來了，我用不同的方式部署與執行，會得到不同的結果：

1.  使用自封式部署 (SCD)
    
    ```bash
    dotnet publish -c DEBUG --nologo --self-contained -r win-x64 -o dist1
    ```
    
    若這樣執行：
    
    ```bash
    dist1\MyApp.exe
    ```
    
    這時 `Environment.ProcessPath` 會回傳 `dist1\MyApp.exe`，而 `System.Reflection.Assembly.GetEntryAssembly().Location` 會回傳 `dist1\MyApp.dll`。
    
    若這樣執行：
    
    ```bash
    dist1\MyApp.exe
    ```
    
    這時 `Environment.ProcessPath` 會回傳 `C:\Program Files\dotnet\dotnet.exe`，而 `System.Reflection.Assembly.GetEntryAssembly().Location` 會回傳 `dist1\MyApp.dll`。
    
    如果要抓版本的話，要使用 `System.Reflection.Assembly.GetEntryAssembly().Location` 才抓版本才是對的！👍
    
2.  使用自封式部署 (SCD) 並且使用 `PublishSingleFile` 選項發佈單一執行檔
    
    ```bash
    dotnet publish -c DEBUG --nologo --self-contained -r win-x64 -p:PublishSingleFile=true -p:DebugType=embedded -p:IncludeNativeLibrariesForSelfExtract=true -o dist2
    ```
    
    若這樣執行：
    
    ```bash
    dist2\MyApp.exe
    ```
    
    這時 `Environment.ProcessPath` 會回傳 `dist2\MyApp.exe`，而 `System.Reflection.Assembly.GetEntryAssembly().Location` 則會回傳 `""` 空字串。
    
    如果要抓版本的話，要使用 `Environment.ProcessPath` 才抓版本才是對的！👍
    

不同的發佈方法，會有完全不同的判斷結果，這真的是一個地雷。

不過，在徹底釐清上述特性後，我終於寫出了一個滿意的版本：

```csharp
string GetVersionInfo()
{
    var location = System.Reflection.Assembly.GetEntryAssembly()?.Location;

    if (string.IsNullOrEmpty(location))
    {
        location = Environment.ProcessPath;
    }

    if (!string.IsNullOrEmpty(location))
    {
        FileVersionInfo fileVersionInfo = FileVersionInfo.GetVersionInfo(location);
        return fileVersionInfo.FileVersion ?? "";
        // fileVersionInfo.FileVersion 屬性為 string? 型別，但應該不可能沒有檔案版本，因為 .NET 有預設值 1.0.0.0
    }
    else
    {
        return "";
    }
}
```

這樣就可以在任何情況下都能正確取得版本號了！👍

### 結語

在 .NET 有很多方法可以取得當前程式所在的路徑，但是不同的取得方法都有些非常細微不同的地方：

-   `Environment.CurrentDirectory` 當前工作目錄
-   `Directory.GetCurrentDirectory` 當前工作目錄
-   `System.AppContext.BaseDirectory` 取得組件解析器用來探測組件的基礎目錄的檔案路徑
-   `Assembly.GetCallingAssembly().Location` 呼叫當前函式的函式所在的組件檔案路徑
-   `Assembly.GetEntryAssembly().Location` 程式進入點的那個組件的檔案路徑
-   `Assembly.GetExecutingAssembly().Location` 包含當前函式的組件檔案路徑

之所以會有多個相似的路徑，肯定是在不同的時空背景下有著不同的用途，所以我們在寫程式的時候，絕對不能以「我的電腦沒問題」來含糊帶過，若有這樣的想法，那就是阻礙你進步的元兇。

我在面試工程師的時候，也發現到了這個現象，很多人寫 Code 寫了很多年，但是對於一些基本的問題，卻無法回答出來。我並不是說這些人不會寫 Code，也不是說這些人沒有經驗，大家能在這個業界待個 5 ~ 10 年，肯定都有一定的解決問題能力，至少找答案的能力可能不差。但是，當面臨到需要提供高品質軟體的專案時，這些人就會顯得力不從心，因為他們的基礎知識不夠扎實，寫 Code 的當下無法保證是對的，甚至於沒意識到有其他可能的錯誤，進而導致無法穩定產出高品質的軟體。

這一年多來，生成式 AI 發展的非常快，但我聽到許多人抱怨 ChatGPT 有太多的幻覺問題，而且是所有 LLM (大語言模型) 都擁有程度不一的幻覺問題。不過，你真的認真思考一下就會發現，其實「人類」的幻覺才真的超級嚴重！

不相信嗎？你只要請任何一位從業多年的**軟體工程師**解釋一個**抽象概念**或**程式碼的運作原理**時，最好在面試的時候問，因為他們為了求表現，一定會想辦法說出自己的「理解」，即便理解是錯誤的，他們也是會很有自信的說出他認為的「觀念」或「邏輯」，但他們自己不會覺得那是「幻覺」啊！😆

有趣的是，有時候我分享我的觀念給對方時，還有人會堅決不認為自己有錯，因為那是他多年來堅若磐石的理解。我有時候也不見得會認為自己永遠都是對的，但我自己如果有幻覺，我自己是不可能會知道的對吧？沒錯，那 ChatGPT 也一樣！所以怎麼辦呢？我覺得最好的方法，就是參與社群活動，多與人接觸，不要自己一個人閉門造車，將所學知識分享出來，讓別人來檢驗你的觀念，透過不斷的正向回饋，你的幻覺問題就會越來越少，也才能夠不斷的進步。👍

所以，我們在寫程式的時候，應該要有一個好習慣，將不理解的事情寫下來，然後等有空的時候再去查。老實說，我這樣的筆記有好幾千份，所以我很多事情都只是略懂，要等有空的時候才會去深入瞭解細節。

### 相關連結

-   Microsoft Learn
    -   [AppContext Class (System) | Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/api/system.appcontext)
    -   [Environment Class (System) | Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/api/system.environment)
        -   [Environment.ProcessPath Property (System) | Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/api/system.environment.processpath?view=net-6.0&WT.mc_id=DT-MVP-4015686)
    -   [Assembly Class (System.Reflection) | Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/api/system.reflection.assembly)
-   Others
    -   [API Proposal: get full path of current process executable #40862](https://github.com/dotnet/runtime/issues/40862)
    -   [How do I find which directory my .NET Core console application was started in or is running from? - Scott Hanselman's Blog](https://www.hanselman.com/blog/how-do-i-find-which-directory-my-net-core-console-application-was-started-in-or-is-running-from)
    -   [PublishSingleFile runtime exception when \*.deps.json is missing · Issue #35493 · dotnet/runtime](https://github.com/dotnet/runtime/issues/35493)
    -   [c# - How to get the path of the directory where the .exe file for a .Net Core console application is located? - Stack Overflow](https://stackoverflow.com/a/60568026/910074)
    -   [c# - Cannot get original executable path for .NET Core 3.0 single file '/p:PublishSingleFile=true' - Stack Overflow](https://stackoverflow.com/a/69730560/910074)
    -   [.net - What are differences between AssemblyVersion, AssemblyFileVersion and AssemblyInformationalVersion? - Stack Overflow](https://stackoverflow.com/a/802038/910074)
