分類: C#

支援TestContainer資料庫整合自動化測試的Clean Architecture專案模版-2024年版總結

支援TestContainer資料庫整合自動化測試的Clean Architecture專案模版-2024年版總結

在前幾份工作透過課程學過單元測試與重構,工作領域就是較多的傳統三層式架構(ex: Presentation-Service-Repository),直到前一份接觸到了實務的Clean Architecture方案開始(約莫三年),發現了C#開發的新天地,終於有一種先前課程單元測試與重構、極速開發學到的技巧有機會應用到工作上的感覺。

因此最近在多個產品領域逐步試驗抽換,有些產品是從0-1,我覺得相對容易,只要開發Pattern形成,其實風格跟文化就會自動形成。

大概的要素如下:

  1. Clean Architecture的專案模版(EFCore .NET+ MediatR-CQRS)
  2. 自動化 IntegrationTest + UnitTest

Clean Architecture的專案模板(EFCore .NET+ MediatR-CQRS)

傳統 N 層架構與 Clean Architecture 的差異

最大的不同在於 依賴注入(DI) 的引入後,Infrastructure 層參考 Application 層,而 API 層 則參考 Application 層MediatR 物件。MediatR 提供了一種 標準化的行為,讓使用層(Client 或 API)可以透過抽象方法進行呼叫(類似 Invoke Action)。透過 IRequest + IRequestHandler,請求會標準化地傳遞至 Application 層 執行。這樣的設計提高了 可測試性,因為大部分的 業務邏輯內聚在 Application 層,不像傳統架構可能將部分邏輯散落在 Web API 等層級中。當然,開發者仍然可以 不遵循原則,把邏輯寫得分散,這就需要有意識地去避免這種情況。在這樣的架構下,Web API 的 Controller 只需專注於與 IHttpContext 交互,或處理 Request-Level 事務,例如: Mapping Command / Query 物件

到了 Application 層,應該只剩下 業務邏輯,不應與 HttpContext 這類原生或底層物件耦合。這樣能大幅提高 Application 層的可測試性。反之Infrastructure 層 應該完全封裝對外部系統(如 Redis、資料庫(DB)、其他第三方套件)的實作,外部專案只能透過 Interface 操作,確保解耦與可維護性。

Clean Architecture 中,DbContext 屬於 Infrastructure 層,而 Entities 則歸屬 Domain 層。實際上,Entity 本質上就是資料庫表的物件對應。如果 Schema 設計符合 Use Case,資料操作起來就會 更直覺,就像在處理業務邏輯,而不只是冷冰冰的 SQL 操作(如 Insert、Update、Delete)。

使用了EntityFramework後,雖然我們可以透過 DI 綁定 IDBContext,讓任意使用端操作資料庫。這樣的做法 雖然方便,但不建議長期使用。或許在 開發初期,這種方式有助於 自動化測試,因為 TDD 產生的測試可以直接驗證資料庫行為。然而,當系統進入 重構階段,應將 IDBContext 介面Application 層 提取到 Service 層,這樣的調整 將會非常輕鬆,同時也能讓架構更符合 Clean Architecture 原則

自動化 IntegrationTest + UnitTest

單元測試的 3A 原則(Arrange, Act, Assert),以及 Mock、Fake、Stub 等技巧固然重要,但這裡想強調的是一個 觀念上的轉變。我認為 單元測試不僅僅是函式層級的測試,而是 只要測試對象可以完全控制,就能算是單元測試。如果一個函式綁定了資料庫操作,是否還能進行單元測試?我認為可以。只要能在測試前準備好資料庫,並在測試結束時清除資料,那麼就能夠 更真實地模擬函式的行為

這樣的做法避免了繁瑣的 Mock 設定,因此 TestContainer 應運而生。過去,準備一個測試用資料庫相對麻煩,不僅需要在本機安裝,還要在 CI/CD 環境中對應不同的資料庫實例。但隨著 Docker 的出現,以及 微軟支援 Docker 版 SQL Server,這個問題得到了有效解決。因此,因為「資料庫」難以”測試”已不應再成為藉口

曾有人問我,許多業務邏輯因為過度依賴資料庫,而導致難以進行良好的單元測試。常見的解法是 將資料庫操作封裝進 IRepository,但接下來的問題是:我們是否應該對 IRepository 進行 Mock?我的結論是:看需求與團隊接受程度。如果我們可以 自動建置資料庫,且 IRepository 主要對接資料庫,那麼就應該讓測試驗證實際的資料庫操作。如果不測試,我認為測試是不完整的,這樣的測試結果也不太令人安心。至於 IProxy 介接外部 API 的部分,我認為這屬於 異質系統,通常交由外部團隊維護,因此建議 Mock 掉

TestContainer 與 Integration Test

在了解 TestContainer 的概念後,我們的目標是讓 每次測試(包含 CI Pipeline)都能進行 Integration Test。因此,我建議採取以下做法:

1. 透過 Docker 自動建立資料庫

2. 自動建置 Schema(可參考 Repository 中 IntegrationTest 的 BeforeRun)

3. 自動升級資料庫版本,確保 Application 依賴的資料庫狀態正確

如此一來,撰寫每個 Test Case 時,數據層都能確保已被測試。

最後,我建議搭配 Database.Reset 套件(目前支援 SQL ServerPostgreSQL),以便快速重置資料庫,確保每次測試環境的一致性。這樣一來,我們就能實踐 「整合單元測試」(Integration Unit Test)。

維護成本與 Schema 同步

維護成本主要來自於 Schema 的同步,這部分可由 DBA 維護,或 團隊自行定義同步方式。這裡沒有標準做法,重點是讓流程 舒適、易於維護,才能長期執行下去。

這些做法在我們團隊已經試驗超過半年,整體來說 可行,團隊成員也已經習慣撰寫測試,並且包含 數據庫驗證測試。目前,我正協助 其他產品團隊 導入 自動化數據庫測試流,調整適合他們的方案。

當然,這個過程難以避免 一定程度的重構,而且不同方案各有風險。由於大多數系統缺乏良好的測試保護,導致改造充滿挑戰。然而,透過 測試技巧,可以逐步 抽離 Interface(這是我認為最重要的一步)。

整體來看,主要的挑戰在於 改造策略與推進進度,但這 並非技術或測試不可行的問題。這部分有機會的話,我會在後續進一步分享。

Clean Architecture 的一般化方案

為了讓 微軟 C# Application 能夠 快速理解並導入 Clean Architecture,我對這套架構進行了一定程度的 一般化整理,並已經在 GitHub 上建立範本。這不僅是個紀錄,也能作為 範本,方便有需要的團隊 直接導入或參考

連結如下: https://github.com/pin0513/ECAppForCleanArchitecture

todo: 會持續將 MediatR的案例整合進去哦

c# 冗餘的 await 調用

c# 冗餘的 await 調用

最近在CodeReview一些程式碼的時候,看到這行字

冗餘的 await 調用

• 如果結果已經是 Task,不需要重複 await,這樣可以簡化代碼。

想起來有時候我們確實在一些方法並沒有回傳值的問題而定義了回傳的Task

但這時候使用端仍往往習慣去await這個Task

這時建議的移除await,”似乎”是反習慣性的建議

因此我想說,用一些具體的例子,看一下在async 方法下 不去await一個 Task方法時,會不會有什麼影響

結論: 不會有影響~

簡易的程式碼如下:

Program.cs

Console.WriteLine("1");

var test = new Test();

test.Method1();

Console.WriteLine("2");

Console.ReadLine();


public class Test
{
    async Task Method2()
    {
        Console.WriteLine("3");
    }
    
    async Task<int> Method3()
    {
        Console.WriteLine("6");
        return 7;
    }
    
    public void Method1()
    {
        Method2();
        Console.WriteLine("4");
        var value = Method3();
        Console.WriteLine("5");
        Console.WriteLine(value.Result);
    }
}


Console的執行順序結果會打印 出什麼結果?

實作RSA + AES 的檔案加密算法POC

實作RSA + AES 的檔案加密算法POC

為了做到檔案的加密(後來才知道其實用GPG就可以了)

一開始我只是想說用現成套件不同的 金鑰key值作為參數,結合ChatGpt就開始做POC看看了

大不了是換演算法的問題,結果其實數位簽章 除了格式、作業系統管理的模式、公私鑰檔案其實也有不同的規定

但大至上心得是一開始想說讓 檔案加密,而且是透過Public Key進行,然後對方用 Private Key解密,所以我出發點就是用RSA演算法去進行加密,結果發現怎麼都會發生長度錯誤的問題。

後來深究後才發現RSA的加密無法對長度太大的東西進行,所以後來換成短字串果然就可以了

因此思考了加解密該做的事情後,就做了一個AES + RSA的POC,RSA主要用來加密 AES的Key

後來果然發現這個跟GPG的作法有點像~~

實作POC如下∶
using System.Security.Cryptography;

string keyBase64 = string.Empty;
using (Aes aesAlgorithm = Aes.Create())
{
aesAlgorithm.KeySize = 256;
aesAlgorithm.GenerateKey();
keyBase64 = Convert.ToBase64String(aesAlgorithm.Key);
Console.WriteLine($”Aes Key Size : {aesAlgorithm.KeySize}”);
Console.WriteLine(“Here is the Aes key in Base64:”);
Console.WriteLine(keyBase64);
Console.ReadKey();
}

string filePath = “/Users/paul_huang/Desktop/FileEncryptTest”;
GenerateKeys(filePath);

// 設定公鑰和私鑰檔案路徑
string publicKeyPath = Path.Combine(filePath, “publicKey.pem”);
string privateKeyPath = Path.Combine(filePath, “privateKey.pem”);

// 設定金鑰的位元數
var keyString = keyBase64;

// 檢查檔案是否存在
if (!File.Exists(publicKeyPath) || !File.Exists(privateKeyPath))
{
Console.WriteLine(“找不到金鑰檔案。請確保金鑰檔案存在。”);
return;
}

// 讀取公鑰
string publicKey = File.ReadAllText(publicKeyPath);

// 讀取私鑰
string privateKey = File.ReadAllText(privateKeyPath);

// 設定要加密的檔案路徑

// 加密檔案
var keyFileName = “key.txt”;
var keyFilePath = Path.Combine(filePath, keyFileName );
if (File.Exists(keyFilePath) == false)
{
File.WriteAllText(keyFilePath, keyString);
}
else
{
keyString = File.ReadAllText(keyFilePath);
}

EncryptKeyFile(keyFilePath, publicKey);

DecryptKeyFile($”{keyFilePath}.encrypted”, privateKey);


// var encryptedKey = $”{keyFilePath}.encrypted”;

var encryptFile = “paul.jpg”;
var encryptFilePath = Path.Combine(filePath, encryptFile);


var encryptedFileContentString = Encrypt(Convert.ToBase64String(File.ReadAllBytes(encryptFilePath)), keyString);
File.WriteAllText(encryptFilePath+”.encrypted”, encryptedFileContentString);

File.WriteAllBytes(encryptFilePath + “decrypted”, Convert.FromBase64String(Decrypt(encryptedFileContentString, keyString)));

static void EncryptKeyFile(string filePath, string publicKey)
{
try
{
byte[] dataToEncrypt = File.ReadAllBytes(filePath);

using var rsa = new RSACryptoServiceProvider(2048);
rsa.FromXmlString(publicKey);

byte[] encryptedData = rsa.Encrypt(dataToEncrypt, false);

// 寫入加密後的檔案
File.WriteAllBytes($”{filePath}.encrypted”, encryptedData);

Console.WriteLine(“檔案已成功加密。”);
}
catch (Exception ex)
{
Console.WriteLine($”加密時發生錯誤: {ex.Message}”);
}
}

static void DecryptKeyFile(string encryptedFilePath, string privateKey)
{
try
{
byte[] encryptedData = File.ReadAllBytes(encryptedFilePath);

using (RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(2048))
{
rsa.FromXmlString(privateKey);

byte[] decryptedData = rsa.Decrypt(encryptedData, false);

// 寫入解密後的檔案
File.WriteAllBytes($”{encryptedFilePath}.decrypted”, decryptedData);

Console.WriteLine(“檔案已成功解密。”);
}
}
catch (Exception ex)
{
Console.WriteLine($”解密時發生錯誤: {ex.Message}”);
}
}

static string Encrypt(string plainText, string key)
{
using (Aes aesAlg = Aes.Create())
{
aesAlg.KeySize = 256; // Set key size to 256 bits
aesAlg.Key = Convert.FromBase64String(key);
aesAlg.IV = new byte[16]; // AES uses a 128-bit IV

ICryptoTransform encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV);

using (MemoryStream msEncrypt = new MemoryStream())
{
using (CryptoStream csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write))
{
using (StreamWriter swEncrypt = new StreamWriter(csEncrypt))
{
swEncrypt.Write(plainText);
}
}

return Convert.ToBase64String(msEncrypt.ToArray());
}
}
}

static string Decrypt(string cipherText, string key)
{
using (Aes aesAlg = Aes.Create())
{
aesAlg.KeySize = 256; // Set key size to 256 bits
aesAlg.Key = Convert.FromBase64String(key);
aesAlg.IV = new byte[16];

ICryptoTransform decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV);

using (MemoryStream msDecrypt = new MemoryStream(Convert.FromBase64String(cipherText)))
{
using (CryptoStream csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
{
using (StreamReader srDecrypt = new StreamReader(csDecrypt))
{
return srDecrypt.ReadToEnd();
}
}
}
}
}

static void GenerateKeys(string path)
{
try
{
using (RSACryptoServiceProvider rsa = new RSACryptoServiceProvider(2048))
{
// 公鑰
string publicKey = rsa.ToXmlString(false);
Console.WriteLine(“公鑰:”);
Console.WriteLine(publicKey);

// 私鑰
string privateKey = rsa.ToXmlString(true);
Console.WriteLine(“\n私鑰:”);
Console.WriteLine(privateKey);

// 將金鑰寫入檔案
File.WriteAllText(Path.Combine(path, “publicKey.pem”), publicKey);
File.WriteAllText(Path.Combine(path, “privateKey.pem”), privateKey);

Console.WriteLine(“\n金鑰已成功生成並保存至檔案。”);
}
}
catch (Exception ex)
{
Console.WriteLine($”生成金鑰時發生錯誤: {ex.Message}”);
}
}

static string GenerateRandomKey(int length)
{
// 使用 RNGCryptoServiceProvider 產生安全的亂數
using (var rng = new RNGCryptoServiceProvider())
{
// 建立位元組陣列來存放隨機資料
byte[] randomBytes = new byte[length];

// 將亂數填入位元組陣列
rng.GetBytes(randomBytes);

// 將位元組轉換成十六進位字串
string randomKey = BitConverter.ToString(randomBytes).Replace(“-“, “”);

return randomKey;
}
}

[C#]Weather Api串接氣象局OpenApi後加上Redis快取

[C#]Weather Api串接氣象局OpenApi後加上Redis快取

這次的Api例子,以整合氣象局OpenApi為例

以政府機關的OpenApi而言,現在標準上都似乎會整合Swagger提供Api的Documentation,對於瞭解Api的參數與使用方法跟回傳格式還滿便利的

Swagger網址如下

https://opendata.cwb.gov.tw/dist/opendata-swagger.html#/%E9%A0%90%E5%A0%B1/get_v1_rest_datastore_F_C0032_001

關於氣象局目前公開的OpenApi只要加入氣象局會員就可以取得授權碼

接著創建我們的WebApiApplication,我將之命名為dotNetApiWithRedisCacheApplication

在程式的啟動點Program.cs 我們僅保留需要的feature,例如HttpClient, DI生成服務

using dotNetApiWithRedisCacheApplication.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient();
builder.Services.AddTransient<IWeatherService, OpenWeatherMapService>();

var app = builder.Build();
app.UseHttpsRedirection();

app.MapGet("/weather", async (string city, IWeatherService weatherService) =>
    {
        var weather = await weatherService.GetWeatherForTwLocation36HrForecastAsync(city);
        return weather is null ? Results.NotFound() : Results.Ok(weather);
    }).WithName("GetWeatherForecast")
    .WithOpenApi();

app.Run();

不看還好,這次一看,其實發現氣象局的天氣 Api回傳的格式相當的豐富,天氣資訊確實也包含了很多維度的資料,而這些分散的數值都被一般化的定義成Parameter

一一將回傳格式物件定義拆分出來如圖存放

OpenApi還有一個還不錯的特性,就是回傳值會包含Fields的型別定義。所以這一塊也可以視需求取用。

這次 透過IWeatherService去隔離實作,程式碼如下,我定義了一個介面與GetWeatherForTwLocation36HrForecastAsync方法

using System.Text.Json;
using dotNetApiWithRedisCacheApplication.Models;

public interface IWeatherService
{
    Task<WeatherResponse?> GetWeatherForTwLocation36HrForecastAsync(string locationZhTw);
}

namespace dotNetApiWithRedisCacheApplication.Services;

public class OpenWeatherMapService : IWeatherService
{
    private const string _authorizationKey = "氣象局會員授權碼 ";
    private IHttpClientFactory _factory;
    public OpenWeatherMapService(IHttpClientFactory factory)
    {
        _factory = factory;
    }
    public async Task<WeatherResponse?> GetWeatherForTwLocation36HrForecastAsync(string locationZhTw)
    {
        //一般天氣預報-今明36小時天氣預報
        var getNext36HrWeatherForecastApiUrl = "https://opendata.cwb.gov.tw/api/v1/rest/datastore/F-C0032-001";
        var httpClient = _factory.CreateClient();
        string requestUrl =
            $"{getNext36HrWeatherForecastApiUrl}?Authorization={_authorizationKey}&locationName={HttpUtility.UrlEncode(locationZhTw)}";
        var response = await httpClient.GetAsync(requestUrl);
        if (response.IsSuccessStatusCode)
        {
            var responseObject =JsonSerializer.Deserialize<WeatherResponse>(await response.Content.ReadAsStringAsync());
            return responseObject;
        }
        throw new Exception($"StatusCode:{response.StatusCode}, Message:{(await response.Content.ReadAsStringAsync())}");

    }
}

實測參數傳入不同縣市後,也有回傳了(注意這邊有UrlEncoding)

接著來 為Api加上OutputCache,這邊需要視情況決定是否要加outCache,通常也會視資料的更新需求而定。

若需要即時性的資料,那麼快取的時間也要搭配設計。

先調整實測在Program.cs中加入OutputCache看看

nuget起來

加入Program.cs

builder.Services.AddOutputCache();
app.UseOutputCache();

app.MapGet("/weather", async (string city, IWeatherService weatherService) =>
    {
        var weather = await weatherService.GetWeatherForTwLocation36HrForecastAsync(city);
        return weather is null ? Results.NotFound() : Results.Ok(weather);
    }).CacheOutput(a=>a.Expire(TimeSpan.FromMinutes(5)))
    .WithName("GetWeatherForecast")
    .WithOpenApi();

實測看看第一次仍有進中斷點

第二次確實 沒有進入中斷點了

比較一下networks

從outputcache輸出是200ms,快了 應該有5倍左右

當然使用了dotNet的快取,若沒有依賴外部的服務,快取就會在Server端的內存,除非參數不一樣或是Expire到期,否則的話,就會從內存拉資料。

不過若是面臨到多台主機的話,就有可能發生資料內存不一致的情況,例如a主機未到期,b主機已到期,b主機重新拉取資料後,就會與a主機的內存不一致了。

因此若有考慮分散式api的快取,需要集中快取源的話,可以考慮 dotNet原生的redis快取插件(大陸用語?)

首先先用Docker做一台Redis服務

docker run -d --name "/redis-stack-server" -p 6379:6379 redis/redis-stack-server:latest

接著我們在方案中nuget一個Redis套件 Microsoft.Extensions.Caching.StackExchangeRedis

接著在program.cs的AddOutputCache“後面”直接 加入擴充

builder.Services.AddOutputCache().AddStackExchangeRedisCache(a =>
{
    a.InstanceName = "paulApi";
    a.Configuration = "localhost:6379";
}  );
//沒用, 要加在AddOutputCache後面
//builder.Services.AddStackExchangeRedisOutputCache(a => a.Configuration = "localhost:6379");

實測2次請求一樣是相差滿大的,一個沒快取重取openapi,另一次就會走outputcache

實際用RedisManager看,真的有看到outputCache轉存到redis的情況

其RedisKey的結構為 paulApi__MSOCV_GETHTTPSLOCALHOST:7039/WEATHERQCITY=台北市

看的出來是以Url Request的結構來作儲存,因此若你打的QueryString若有變化的話,就不會從這個快取拉資料了。但若又有一樣參數請求命中的話,就可以從Redis拉 上次的Response出來吐回去,而不用重新打一次 你的資料庫或OpenApi

Github:https://github.com/pin0513/dotNetApiWithRedisCacheApplication

本次的教學影片來自於 Nick Chapsas

從影片學dotNet 8的快取:

[C# UnitTest]Mock IHttpClientFactory中生成HttpClient的方法

[C# UnitTest]Mock IHttpClientFactory中生成HttpClient的方法

在撰寫服務時,若有跟外部的資源或Api整合,需要用到HttpClient作互動時
我們通常會使用加入IHttpClientFactory來由底層生成HttpClient,以避免過度使用HttpClient

IHttpClientFactory是可以透過Mock的機制去Setup CreateHttpClient
不過我們HttpClient真的要模擬的是PostAsync的行為,但是卻沒有IHttpClient可以作Mock

網路上查到有另外做一個IHttpClientWrapper,隔離掉HttpClient的實作的方式,但事倍功半

以下 的方法,是最快可以直接Mock掉HttpClient的作法。主要就是透過 Mock

HttpMessageHanlder的方式,詳細寫法範例如下 !

var handlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);
            handlerMock
                .Protected()
                .Setup<Task<HttpResponseMessage>>(
                    "SendAsync",
                    ItExpr.IsAny<HttpRequestMessage>(),
                    ItExpr.IsAny<CancellationToken>()
                )
                .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent(JsonConvert.SerializeObject(new MigrateService.PPAMigrateResponse()
                    {
                        Status = "success",
                        Message = "",
                        Data = new MigrateService.PPAMigrateMemberInfoResponse()
                        {
                            member_id =memberId 
                        }
                    }))
                })
                .Verifiable();
            
            var mockHttpClient = new HttpClient(handlerMock.Object);

            SetupMock<IHttpClientFactory>(a =>
                a.Setup(b => b.CreateClient(It.IsAny<string>())).Returns(mockHttpClient));
[動手做] 將C# Api佈署上 AWS+Github+Jenkins + Docker 的CICD實踐-Jenkins篇

[動手做] 將C# Api佈署上 AWS+Github+Jenkins + Docker 的CICD實踐-Jenkins篇

寫了微軟程式這麼久,雖然都是寫功能比較多,對於DevOps界久聞Jenkins大名,但從未真的使用過Jenkins做過一段完整的CICD流程。出於好奇心,找了一部網路影片,結合C# dotnet core的主題,希望實際 手把手try出如何在aws上做完一個low level的佈署過程 。總不好說自己會用 Azure Pipelin就是有做過 CICD ,終究Azure Pipeline也封裝 簡化了很多東西,做起來已經相對簡單一點了,不過缺點也是彈性不夠大加上指令或任務也不夠透明化。這一版本sonarQube先ignore(主要是 做的時候發現無法啟動,待之後再來補強這段)

這一次的專案範例如下 ,不接 db不接 外部服務,純程式的模擬

github url: https://github.com/pin0513/CICDExample

Program.cs的內容是一個模擬未來五天的天氣預測的Api

using System;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.FeatureManagement;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddFeatureManagement();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.UseHttpsRedirection();

var summaries = new[]
{
“Freezing”, “Bracing”, “Chilly”, “Cool”, “Mild”, “Warm”, “Balmy”, “Hot”, “Sweltering”
};

app.MapGet(“/weatherforecast”, () =>
{
var forecast = Enumerable.Range(1, 5).Select(index => new WeatherForecast()
{
DateTime = DateOnly.FromDateTime((DateTime.Now).AddDays(index)),
Temperature = Random.Shared.Next(-20, 55),
Status = summaries[Random.Shared.Next(summaries.Length)]
}).ToArray();
return forecast;
}).WithName(“GetWetherForecast”)
.WithOpenApi();
app.Run();

public class WeatherForecast
{
public DateOnly DateTime { get; set; }
public int Temperature { get; set; }
public string Status { get; set; }
}

實際跑起來如下 :

接著我們把他Commit與Push到Github

AWS端,我們先創建EC2的VM作為 Jenkins Server

選擇免費方案就好了XD

分別建立3個Instance後如下

我們透過 SSH連線過去,會先發現

paul@IpdeMacBook-Pro aws-cert % ssh -i SSH-KEY-Jenkins.pem ubuntu@18.206.225.245 

Permissions 0644 for 'SSH-KEY-Jenkins.pem' are too open.
It is required that your private key files are NOT accessible by others.
This private key will be ignored.
Load key "SSH-KEY-Jenkins.pem": bad permissions
[email protected]: Permission denied (publickey).

爆了以上的錯誤,代表你的權限開太大了,執行前必須先將你的金鑰改成無法公開檢視

重新輸入paul@IpdeMacBook-Pro aws-cert % sudo chmod 440 SSH-KEY-Jenkins.pem 以後,就可以ssh過去了

在Jenkins的Vm上依序執行以下指令

sudo apt update
sudo apt install openjdk-11-jre

https://www.jenkins.io/doc/book/installing/linux/

curl -fsSL https://pkg.jenkins.io/debian/jenkins.io-2023.key | sudo tee \
  /usr/share/keyrings/jenkins-keyring.asc > /dev/null
echo deb [signed-by=/usr/share/keyrings/jenkins-keyring.asc] \
  https://pkg.jenkins.io/debian binary/ | sudo tee \
  /etc/apt/sources.list.d/jenkins.list > /dev/null
sudo apt-get update
sudo apt-get install jenkins

安裝後,接著到EC2端設定 安全性

接著啟動jenkinks

systemctl status jenkins

跑起來後,會如下圖,會有token到時拿來登入用的,此擷圖不是我的主機,僅示意,安全性至上!

新的作業 -> enter automated-pipeline-aws

接著到Github專案去設定 Webhook

http://18.206.225.245:8080/github-webhook/ (這邊注意要加/)

接著到jenkins測試一下,點擊馬上建置

看一下工作目錄,就有包含最早我們commit的方案資訊

接著到github測試一下webhook

Reference: https://www.youtube.com/watch?v=361bfIvXMBI

建置觸發成功!實際看jenkins的工作目錄也有新的檔案了!

SonarQube Vm透過SSH一樣 的方式登入後,去SonarQube官網下載Community版本

ubuntu@ip-172-31-89-20:~$ wget ttps://binaries.sonarsource.com/Distribution/sonarqube/sonarqube-10.1.0.73491.zip

sudo apt install unzip
unzip sonarqube-10.1.0.73491.zip
/home/ubuntu/sonarqube-10.1.0.73491/bin/linux-x86-64
ubuntu@ip-172-31-89-20:~/sonarqube-10.1.0.73491/bin/linux-x86-64$ ./sonar.sh console

上面指令跑不成功可能是java環境沒裝,所以抄上面jenkins的安裝java指令執行一次

實測Java要裝到18版

sudo apt install openjdk-19-jdk-headless

實際跑起來目前

2023.08.06 05:12:07 INFO  app[][o.s.a.SchedulerImpl] Waiting for Elasticsearch to be up and running的問題 (會暫時卡住)

我們先跳過去做佈署到Docker-Server

paul@IpdeMacBook-Pro aws-cert % ssh -i SSH-KEY-Jenkins.pem [email protected]

ubuntu@ip-172-31-82-131:~$ sudo hostnamectl set-hostname docker
ubuntu@ip-172-31-82-131:~$ /bin/bash
ubuntu@docker:~$ sudo apt update
sudo apt-get install ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings $ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg –dearmor -o /etc/apt/keyrings/docker.gpg $ sudo chmod a+r /etc/apt/keyrings/docker.gpg

執行
echo \
  "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

回到jenkins切換 hostname

sudo hostnamectl set-hostname jenkins

ubuntu@jenkins:~$ sudo su jenkins
ubuntu@jenkins:~$ ssh [email protected] (連到Docker Server)

此時會出現
[email protected]: Permission denied (publickey).

這時先回到docker的那台 vm,用 sudo su進去root

再編輯nano /etc/ssh/sshd_config 檔案 (注意: 是sshd_config)

把以下幾個設定啟用 ,反註解後存檔

PubkeyAuthentication yes
PasswordAuthentication yes 

重啟sshd : systemctl restart sshd (要在root權限下: sudo su)

同時在 root 權限下 passwd ubuntu這個帳號設定密碼

測試jenkins可否連到docker~

ssh [email protected]

若可以連線並驗證密碼通過就會像上圖

回到jenkins的vm透過sudo su jenkins切到jenkins的user透過ssh-keygen產生一個key

接著ssh-copy-id [email protected]

經過了上述的行為,我們透過了jenkins這個user去生成了一個ssh的key加到了docker的server

也就是我可以直接在jenkin vm這台透過 jenkins這個user執行ssh不用密碼就可以跟docker互動

接著我們試著透過 jenkins的管理去設定佈署的docker server

管理 Jenkins => 安裝 ssh2easy的plugin(因為影片中用的就是這個)

設定ServerGroupsCenter

組態中我們可以先試著用 remote shell做一個指令看看

馬上儲存來建置看看,實測如下,有確實執行指令

實際上也有帶上來!!

jenkins建置 C#的準備

sudo apt-get install -y dotnet-sdk-7.0
sudo apt-get install -y aspnetcore-runtime-7.0
sudo apt install nuget
sudo apt install zip

我們預計從Jenkins發佈程式後,上傳 zip到docker-server去處理
建立Build Steps

執行 Shell
dotnet publish ${WORKSPACE}\\WeatherWebApplication\\WeatherWebApplication.sln -c Release -o out -r linux-x64 --self-contained

執行 Shell

cd out && zip -qr output.zip .

Remote Shell
touch ${WORKSPACE}\\WeatherWebApplication\\out\\output.zip

最後我們設定一個
建置後動作 Delete workspace when build is done(這個是一個plugin)

上面的建置過程中我曾經發現他會一直出現以下錯誤 
error NETSDK1005: Assets file '/var/lib/jenkins/workspace/automated-pipeline-aws/WeatherWebApplication/WeatherWebApplication/obj/project.assets.json' doesn't have a target for 'net7.0'

後來發現,我雖然裝的是net7的SDK,但是在obj的project.assets.json中有綁到target: net8.0的配置,而且 obj目錄不小心commit了,所以我後續使用上面的delete workspace when is failure做清除後
再重新建置一次,這時改成is Success再清就好了,因為若是失敗的話,就還可以檢查問題
所以jenkins在工作目錄的操作預設是只會做覆蓋。所以錯誤的配置調整後,可能還是會錯誤這點在釐清問題的時候還是要看一下。

####################################
execute command exit status -->0
##########################################################################
[automated-pipeline-aws] $ /bin/sh -xe /tmp/jenkins16435309995668868877.sh
+ cd out
+ date +%F
+ mv output.zip output-2023-08-08.zip
+ date +%F
+ scp /var/lib/jenkins/workspace/automated-pipeline-aws/out/output-2023-08-08.zip [email protected]:~/upload
[WS-CLEANUP] Deleting project workspace...
[WS-CLEANUP] Deferred wipeout is used...
[WS-CLEANUP] done
Finished: SUCCESS

終於建置到移轉到docker-server,也看到docker-vm上有我們scp過去的檔案

接著我們嘗試加入一個Remote Shell

這一段主要是希望透過編碼可以做到整理Deploy上去的zip檔案。未來可以做版本備份的概念,但其實期望應該是可以做到日期流水編,這邊先簡單用日期做

在Docker-Server端為了設定權限,加上以下的script

ubuntu@docker:~/upload/app$ sudo usermod -aG docker ubuntu
ubuntu@docker:~/upload/app$ newgrp docker

做到不用sudo 可以執行docker

因為.net core在Docker的相容性已經有目共睹了,所以這邊索性調整Sample Project給的DockerFile

FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM base AS final
WORKDIR /app
COPY *.* .
ENTRYPOINT ["dotnet", "WeatherWebApplication.dll"]

註: 測試Jenkins時,建議都先 在linux主機上驗證,通常 可以跑的指令 在jenkins上不太會出錯。

透過Remote Shell定義Docker Command

經過幾次調整後,終於可以運作了

因為是綁8085 port,所以docker server的安全性設定要開放8085 port

大功告成

我們試試看改程式後,整個運作起來看看正不正常

Enumerable 從1-5筆改成1-10筆

Push後,一路觸發了

哈!馬上建置失敗

因為Docker 的name已經重疊了,無法建立,為了簡單驗證,所以調整一下docker run 的shell,先 停止再 重新run,設定如下(–rm實測無法動態移除存在的container,所以我先改成stop再 rm):

cd ~/upload/app
docker build -t docker-app-image .
docker stop weather-app
docker rm weather-app
docker run -d -p 8085:80 --name=weather-app --rm docker-app-image

最後成功了!!

改成50筆也不是問題

讚啦…

首次手把手建立C# .net core程式透過jenkins模擬 CICD的流程 ,雖然還沒自動呼叫單元測試,但也算是解決了自已長久以來好奇的部分細節。

不過實務上每個細節都還有很多改進空間(廢話),例如cd的部分不太可能像我這邊用 stop的方式 中斷 服務,應該是能透過 Docker Swarm或是K8加入node的方式,也要更提早考慮封裝image的問題。以確保不是在prod環境才進行封裝,而是在stage封裝 後在 其他環境pull下來的image已經是穩定的。

這次的案例雖簡化許多,不過能打通一解我心中長久之謎,在很多細節真的是需要一一擊破,僅作為筆記 記錄此行~~

.Net 8的計量新框架 (Metrics)

.Net 8的計量新框架 (Metrics)

想當年,要做到.Net的監控機制,就是要做到這麼多的事情

可能包含要自己設計Log,考慮如何非同步寫入分庫DB,或是另外獨立呼叫外部服務(ex.Sentry)。

當然外部服務是肯定方便又快速的整合方式,但是就是貴森森,而且還有服務綁架問題(就是用一用 ,你就很難轉移到其他平台上)

曾幾何時,我們以前的公司也有架構team,專門搞了一個整合Grafana, Kibana, ElasticSearch的底層平台過。當然當初要架這些東西還沒容器化前,相信也是有相當的維運成本(現在也是有啦)

不過自從容器化後,這些事情都變的好像更容易指令化,就可以打包好一個獨立的自有服務。

當然 今天看到這篇.Net 8支援 Metrics的功能後,其實真的簡化了很多以往要自已造輪子的工。

有興趣可以看 .Net網紅的Demo

而官方文件也給出了手把手的教學!!

https://learn.microsoft.com/zh-tw/dotnet/core/diagnostics/metrics-collection