使用 .NET CLI 快速建立 Web API 用戶端函式庫的方法 | The Will Will Web

The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

使用 .NET CLI 快速建立 Web API 用戶端函式庫的方法

在 Visual Studio 2019 裡面使用含有 OpenAPI 規格的 Web API 已經是十分便利,只要規格寫的好,Web API 用戶端函式庫只要一秒鐘就可以產生。但其實這些好用的功能背後都是靠 MSBuild 與 NSwag 做到,今天花了一整天把所有技術細節釐清,釐清之後對這整套作法是如此的豁然開朗,感覺很棒。這篇文章我就來寫寫今日的研究心得!

使用 Microsoft.dotnet-openapi 工具

微軟有發展一套 Microsoft.dotnet-openapi 工具,並且內建到 Visual Studio 2019 之中,但事實上你完全可以用命令列的方式執行。這也意味著你在 macOS 或 Linux 也可以利用這個工具快速產生專案所需的 Web API 用戶端函式庫。

  1. 建立 Console 專案並透過 VSCode 開啟專案

    dotnet new console -n apiclient1
    code apiclient1
    
  2. 安裝 NSwag.ApiDescription.Client 套件

    dotnet add package NSwag.ApiDescription.Client
    

    注意: 這個套件同時包含了 NSwag.MSBuildMicrosoft.Extensions.ApiDescription.Client 套件的相依性,所以會自動安裝起來。

  3. 將 OpenAPI v3 規格文件加入專案

    先安裝 Microsoft.dotnet-openapi 這套 .NET 全域工具

    dotnet tool install -g Microsoft.dotnet-openapi
    

    將遠端 Web API 的 OpenAPI v3 規格加入專案

    dotnet openapi add url -p apiclient1.csproj https://localhost:5001/swagger/v1/swagger.json
    

    當你將 OpenAPI v3 規格文件加入專案後,專案內會新增一個 swagger.json 檔案,那是因為我們的 URL 結尾的檔案是 swagger.json 的關係。

    如果你無法從開發環境連到遠端的 Web API 以取得 OpenAPI 文件,你也可以人工取得該檔案,自己放到專案中,然後輸入以下命令加入檔案:

    dotnet openapi add file -p apiclient1.csproj swagger.json
    

    加入 OpenAPI 規格文件後,你的 *.csproj 專案檔中會添加一段 <ItemGroup> 項目,其內容如下:

    <ItemGroup>
      <OpenApiReference Include="swagger.json" SourceUrl="https://localhost:5001/swagger/v1/swagger.json" />
    </ItemGroup>
    

    注意: 上述命令會自動安裝 NSwag.ApiDescription.ClientNewtonsoft.Json 套件。不過自動安裝的 NSwag.ApiDescription.ClientNewtonsoft.Json 套件並不會套用 <PrivateAssets>all</PrivateAssets> 屬性,所以我才在上一個步驟先手動安裝了這個套件,手動安裝的設定才是對的。

  4. 建置專案

    接著我們直接執行建置命令:

    dotnet build
    

    這個動作會自動產生一個 obj\swaggerClient.cs 的類別,而這個類別就是我們專案可以用的 C# 用戶端函式庫,裡面將包含我們在 OpenAPI 中定義的所有模型,以及所有 API 的操作方法(Operator),全部都是強型別的定義。

  5. 更新 OpenAPI v3 規格與重新產生原始碼

    當你的 Web API 有更新時,其 OpenAPI 規格也會有改變,這時我們的 Web API 用戶端函式庫就要連帶更新,以下是更新的命令:

    dotnet openapi refresh -p apiclient1.csproj https://localhost:5001/swagger/v1/swagger.json
    

    注意:更新完之後,要記得刪除 obj 目錄,並重新執行 dotnet build 才能重新產生新版的程式碼。

關於 <OpenApiReference> 的技術細節

基本上,你安裝的 NSwag.ApiDescription.Client 套件,包含了一個 NSwag.MSBuild 套件,以及一個 Microsoft.Extensions.ApiDescription.Client 套件,而這三個套都包含了一些 MSBuild 的屬性與目標定義。

  1. NSwag.ApiDescription.Client

    NSwag.ApiDescription.Client.props 屬性檔,定義了 OpenApiReference.CodeGenerator 這個屬性的預設值,你可以輕易的看出預設值為 NSwagCSharp,也就是說這套工具預設會產生 C# 的 Web API 用戶端函式庫。

    <?xml version="1.0" encoding="utf-8" standalone="no"?>
    <Project>
      <!-- Reset well-known metadata of the code generator item groups to make NSwag C# generator the default. -->
      <ItemDefinitionGroup>
        <OpenApiReference>
          <CodeGenerator>NSwagCSharp</CodeGenerator>
        </OpenApiReference>
        <OpenApiProjectReference>
          <CodeGenerator>NSwagCSharp</CodeGenerator>
        </OpenApiProjectReference>
      </ItemDefinitionGroup>
    </Project>
    

    NSwag.ApiDescription.Client.targets 目標檔案,定義了 GenerateNSwagCSharpGenerateNSwagTypeScript 這兩個目標,可以讓你輕鬆透過 MSBuild 的定義 (也就是 *.csproj 專案檔),就能夠輕鬆的產出 Web API 用戶端函式庫的程式碼。而所有會執行的命令與參數,也都完完整整的寫在這裡,你只要看的懂,就可以自行客製調整輸出的結果。

    <?xml version="1.0" encoding="utf-8" standalone="no"?>
    <Project>
      <PropertyGroup>
        <_NSwagCommand>$(NSwagExe)</_NSwagCommand>
        <_NSwagCommand
            Condition="'$(MSBuildRuntimeType)' == 'Core'">dotnet --roll-forward-on-no-candidate-fx 2 "$(NSwagDir_Core31)/dotnet-nswag.dll"</_NSwagCommand>
      </PropertyGroup>
    
      <!-- OpenApiReference support for C# -->
    
      <Target Name="GenerateNSwagCSharp">
        <ItemGroup>
          <!-- @(CurrentOpenApiReference) item group will never contain more than one item. -->
          <CurrentOpenApiReference>
            <Command>$(_NSwagCommand) openapi2csclient /className:%(ClassName) /namespace:%(Namespace)</Command>
          </CurrentOpenApiReference>
          <CurrentOpenApiReference>
            <Command Condition="! %(FirstForGenerator)">%(Command) /GenerateExceptionClasses:false</Command>
          </CurrentOpenApiReference>
          <CurrentOpenApiReference>
            <Command>%(Command) /input:"%(FullPath)" /output:"%(OutputPath)" %(Options)</Command>
          </CurrentOpenApiReference>
        </ItemGroup>
    
        <Message Importance="high" Text="%0AGenerateNSwagCSharp:" />
        <Message Importance="high" Text="  %(CurrentOpenApiReference.Command)" />
    
        <Exec Command="%(CurrentOpenApiReference.Command)" LogStandardErrorAsError="true" />
      </Target>
    
      <!-- OpenApiReference support for TypeScript -->
    
      <Target Name="GenerateNSwagTypeScript">
        <ItemGroup>
          <!-- @(CurrentOpenApiReference) item group will never contain more than one item. -->
          <CurrentOpenApiReference>
            <Command>$(_NSwagCommand) swagger2tsclient /className:%(ClassName) /namespace:%(Namespace)</Command>
          </CurrentOpenApiReference>
          <CurrentOpenApiReference>
            <Command>%(Command) /input:"%(FullPath)" /output:"%(OutputPath)" %(Options)</Command>
          </CurrentOpenApiReference>
        </ItemGroup>
    
        <Message Importance="high" Text="%0AGenerateNSwagTypeScript:" />
        <Message Importance="high" Text="  %(CurrentOpenApiReference.Command)" />
        <Exec Command="%(CurrentOpenApiReference.Command)" LogStandardErrorAsError="true" />
      </Target>
    </Project>
    
  2. NSwag.MSBuild

    NSwag.MSBuild.props 屬性檔,裡面包含了 NSwag 執行檔的所在路徑。

    <?xml version="1.0" encoding="utf-8" ?>
    <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
      <PropertyGroup>
        <NSwagExe>"$(MSBuildThisFileDirectory)../tools/Win/NSwag.exe"</NSwagExe>
        <NSwagExe_x86>"$(MSBuildThisFileDirectory)../tools/Win/NSwag.x86.exe"</NSwagExe_x86>
        <NSwagExe_Core21>dotnet "$(MSBuildThisFileDirectory)../tools/NetCore21/dotnet-nswag.dll"</NSwagExe_Core21>
        <NSwagExe_Core31>dotnet "$(MSBuildThisFileDirectory)../tools/NetCore31/dotnet-nswag.dll"</NSwagExe_Core31>
        <NSwagExe_Net50>dotnet "$(MSBuildThisFileDirectory)../tools/Net50/dotnet-nswag.dll"</NSwagExe_Net50>
    
        <NSwagDir>$(MSBuildThisFileDirectory)../tools/Win/</NSwagDir>
        <NSwagDir_Core21>$(MSBuildThisFileDirectory)../tools/NetCore21/</NSwagDir_Core21>
        <NSwagDir_Core31>$(MSBuildThisFileDirectory)../tools/NetCore31/</NSwagDir_Core31>
        <NSwagDir_Net50>$(MSBuildThisFileDirectory)../tools/Net50/</NSwagDir_Net50>
      </PropertyGroup>
    </Project>
    
  3. Microsoft.Extensions.ApiDescription.Client

    Microsoft.Extensions.ApiDescription.Client.props 屬性檔,主要用來設定 <OpenApiReference><OpenApiProjectReference> 各屬性的預設值,這裡的設定有助於讓我們在執行 MSBuild.exe 的時候,從命令列直接設定屬性值!(利用 /p:PropertyName=PropertyValue 語法)

    Microsoft.Extensions.ApiDescription.Client.targets 目標檔,裡面有一堆微軟預先定義好的 目標 (Target),用來將需要產生程式碼或文件的動作整合起來。

簡單來說,當你的 *.csproj 專案檔使用了 <OpenApiReference> 項目,就會觸發在 NSwag.ApiDescription.Client.targets 目標檔案中的特定目標,你可以從內容組合出確切的執行命令與參數,其完整的命令如下:

dotnet --roll-forward-on-no-candidate-fx 2 "$(NSwagDir_Core31)/dotnet-nswag.dll" openapi2csclient /className:%(ClassName) /namespace:%(Namespace) /input:"%(FullPath)" /output:"%(OutputPath)" %(Options)

由此可知,透過 MSBuild 來執行 NSwag 命令,你可以在 *.csproj 檔案中可以設定的屬性名稱,大概就只有以下幾種:

  • ClassName

    透過 MSBuild 自動產生的 Web API 用戶端函式庫,其 類別名稱 預設為 OpenAPI 規格的檔案名稱加上 Client 結尾,所以如果檔名是 swagger.json 的話,其類別名稱就是 swaggerClient。因此,我們通常都會特別設定 ClassName<OpenApiReference> 項目的屬性中。

    一般來說我們都會以 Client 當成類別名稱的尾碼,例如:DuotifyServiceClient

  • Namespace

    透過 MSBuild 自動產生的 Web API 用戶端函式庫,其類別所使用的命名空間,就是當前專案的預設命名空間。如果你需要調整的話,可以透過這個屬性進行設定。

  • OutputPath

    透過 MSBuild 自動產生的 Web API 用戶端函式庫,其輸出的實體檔案會置於 obj\ 加上 OpenAPI 規格的檔案名稱 再加上 Client 結尾,所以如果檔名是 swagger.json 的話,其實體檔案名稱就是 obj/swaggerClient.cs

    一般來說,這個實體檔案會在建置的時候自動產生,所以檔名其實不是太重要,但如果想要將實體檔案保留下來,你可能會想取一個更容易理解的實體檔案名稱。

  • Options

    透過 MSBuild 自動產生的 Web API 用戶端函式庫,預設是透過執行 NSwag 命令列工具產生的,而 NSwag 命令列工具有數十個可以客製化輸出的選項,所有額外的選項你都可以透過這個屬性來進行設定!

    首先,你要先安裝 NSwag.ConsoleCore 全域工具:

    dotnet tool install -g NSwag.ConsoleCore
    

    然後透過以下命令查出所有可用的選項:

    nswag help openapi2csclient
    

分享幾個 <OpenApiReference> 設定範例

  1. 預設範例

    <ItemGroup>
      <OpenApiReference Include="swagger.json" SourceUrl="https://localhost:5001/swagger/v1/swagger.json" />
    </ItemGroup>
    
  2. 自訂類別名稱與輸出檔名,建立 interface 方便後續設定 DI

    <ItemGroup>
      <OpenApiReference Include="swagger.json" SourceUrl="https://localhost:5001/swagger/v1/swagger.json">
        <ClassName>DuotifyServiceClient</ClassName>
        <OutputPath>DuotifyServiceClient.cs</OutputPath>
        <Options>/GenerateClientInterfaces:true</Options>
      </OpenApiReference>
    </ItemGroup>
    
  3. 自訂類別名稱與輸出檔名,建立 interface 方便後續設定 DI,改用 System.Text.Json 作為主要 JSON 處理函式

    <ItemGroup>
      <OpenApiReference Include="swagger.json" SourceUrl="https://localhost:5001/swagger/v1/swagger.json">
        <ClassName>DuotifyServiceClient</ClassName>
        <OutputPath>DuotifyServiceClient.cs</OutputPath>
        <Options>/GenerateClientInterfaces:true /JsonLibrary:SystemTextJson</Options>
      </OpenApiReference>
    </ItemGroup>
    
  4. 自訂類別名稱與輸出檔名,從建構式關閉 BaseUrl 的注入、建立 interface 方便後續設定 DI

    <ItemGroup>
      <OpenApiReference Include="swagger.json" SourceUrl="https://localhost:5001/swagger/v1/swagger.json">
        <ClassName>DuotifyServiceClient</ClassName>
        <OutputPath>DuotifyServiceClient.cs</OutputPath>
        <Options>/UseBaseUrl:false /GenerateClientInterfaces:true</Options>
      </OpenApiReference>
    </ItemGroup>
    

    若使用 /UseBaseUrl:false 參數設定,意味著你必須從注入的 HttpClient 設定 httpClient.BaseAddress 屬性,否則將無法正確發出 HTTP 要求。

    以下是從 ASP.NET Core 中設定 HttpClient 服務的設定範例:

    services.AddHttpClient<IDuotifyServiceClient, DuotifyServiceClient>(
        (provider, client) => {
        client.BaseAddress = new Uri(Configuration.GetValue(
            "DuotifyServiceBaseAddress", "https://example.com/"));
    });
    

    詳細用法請參見 Use IHttpClientFactory to implement resilient HTTP requests 文章。

  5. 自訂類別名稱與輸出檔名,從建構式關閉 BaseUrl 的注入、從 Client 類別套用一個基底類別 (BaseClient)

    <ItemGroup>
      <OpenApiReference Include="swagger.json" SourceUrl="https://localhost:5001/swagger/v1/swagger.json">
        <ClassName>DuotifyServiceClient</ClassName>
        <OutputPath>DuotifyServiceClient.cs</OutputPath>
        <Options>/UseBaseUrl:false /ClientBaseClass:BaseClient</Options>
      </OpenApiReference>
    </ItemGroup>
    
  6. 自訂類別名稱與輸出檔名,主要的 Client 類別套用一個基底類別 (Base Class) 並在建構式新增一個可額外傳入的 ClientConfiguration 類別

    <ItemGroup>
      <OpenApiReference Include="swagger.json" SourceUrl="https://localhost:5001/swagger/v1/swagger.json">
        <ClassName>DuotifyServiceClient</ClassName>
        <OutputPath>DuotifyServiceClient.cs</OutputPath>
        <Options>/ClientBaseClass:BaseClient /ConfigurationClass:ClientConfiguration</Options>
      </OpenApiReference>
    </ItemGroup>
    

    這招很適合透過 ClientConfiguration 用來修改與調整 Client 類別的初始狀態

  7. 自動產生 TypeScript 類別與介面,使用 Angular 範本(隱含使用 HttpClient 最為發出 HTTP 的服務)

    <ItemGroup>
      <OpenApiReference Include="swagger.json">
        <SourceUrl>https://localhost:5001/swagger/v1/swagger.json</SourceUrl>
        <CodeGenerator>NSwagTypeScript</CodeGenerator>
        <ClassName>DuotifyServiceClient</ClassName>
        <OutputPath>DuotifyServiceClient.ts</OutputPath>
        <Options>/GenerateClientInterfaces:true /Template:Angular</Options>
      </OpenApiReference>
    </ItemGroup>
    

    Angular 必須在 AppModule 設定一組名為 API_BASE_URL 的 DI 才能讓函式庫正常運作:

    @NgModule({
        imports: [ ... ],
        declarations: [ ... ],
        providers: [
            {
                provide: API_BASE_URL,
                useValue: environment.apiRoot
            },
            ...
        ]
        exports: [ ... ]
    })
    export class AppModule {}
    
  8. 自動產生 TypeScript 類別與介面,使用 Fetch 作為發送 AJAX 的主要方法(Vue 或 React 可以考慮用這個)

    <ItemGroup>
      <OpenApiReference Include="swagger.json">
        <SourceUrl>https://localhost:5001/swagger/v1/swagger.json</SourceUrl>
        <CodeGenerator>NSwagTypeScript</CodeGenerator>
        <ClassName>DuotifyServiceClient</ClassName>
        <OutputPath>DuotifyServiceClient.ts</OutputPath>
        <Options>/GenerateClientInterfaces:true /Template:Fetch</Options>
      </OpenApiReference>
    </ItemGroup>
    
  9. 自動產生 TypeScript 類別與介面,使用 JQueryCallbacks 作為發送 AJAX 的主要方法,跨來源呼叫時可一併設定 withCredentials 屬性

    <ItemGroup>
      <OpenApiReference Include="swagger.json">
        <SourceUrl>https://localhost:5001/swagger/v1/swagger.json</SourceUrl>
        <CodeGenerator>NSwagTypeScript</CodeGenerator>
        <ClassName>DuotifyServiceClient</ClassName>
        <OutputPath>DuotifyServiceClient.ts</OutputPath>
        <Options>/GenerateClientInterfaces:true /Template:Angular /WithCredentials:true</Options>
      </OpenApiReference>
    </ItemGroup>
    

刪除 <OpenApiReference> 設定

基本上有兩種方法可以刪除現有的 OpenAPI 參考:

  1. 執行以下 dotnet openapi remove 命令

    dotnet openapi remove -p apiclient1.csproj swagger.json
    
  2. 手動刪除 apiclient1.csproj 中包含 <OpenApiReference><ItemGroup>

請記得將 obj 目錄完整刪除,才不會有殘留的檔案導致編譯錯誤!

如何在執行 dotnet clean 的時候完整清除 binobj 目錄

使用 .NET CLI 執行 dotnet clean 的時候,只會清除 dotnet build 的過程中產生的檔案。簡單來說,就是很多檔案並不會在 dotnet clean 的時候被刪除,像是本篇文章所講的那些自動產生的原始碼,在更新或移除設定的時候都不會自動刪除。這很有可能會導致許多奇怪的問題發生!

這邊我也分享一個小技巧,你只要將以下片段加入到 *.csproj 專案檔中,就可以自動在 dotnet clean 的時候完整清除 binobj 目錄:

<Target Name="PostClean" AfterTargets="Clean">
  <RemoveDir Directories="$(BaseIntermediateOutputPath)" /> <!-- obj -->
  <RemoveDir Directories="$(BaseOutputPath)" /> <!-- bin -->
</Target>

如果只想清空 obj 就好的話,請用以下範例:

<Target Name="PostClean" AfterTargets="Clean">
  <RemoveDir Directories="$(BaseIntermediateOutputPath)" />
</Target>

參考:dotnet clean command won't delete bin and obj folder that produced by dotnet publish · Issue #12304 · dotnet/docs

相關連結