分類: 未分類

支援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的案例整合進去哦

Nodejs並支持 React的CMS解決方案(todo study)

Nodejs並支持 React的CMS解決方案(todo study)

在您提到的选项中,以下的 CMS 可以通过 Docker Compose 构建,并使用 MariaDB 进行数据库连接,类似于 WordPress 的部署方式:

1. Strapi

Docker 支持:Strapi 提供了官方的 Docker 镜像,并有示例供参考。

MariaDB 支持:Strapi 默认支持 MariaDB,您可以在配置中指定。

部署示例

以下是一个使用 Docker Compose 部署 Strapi 和 MariaDB 的示例:

version: '3'

services:

  strapi:

    image: strapi/strapi

    ports:

      - '1337:1337'

    environment:

      DATABASE_CLIENT: mysql

      DATABASE_HOST: db

      DATABASE_PORT: 3306

      DATABASE_NAME: strapi

      DATABASE_USERNAME: root

      DATABASE_PASSWORD: example

    depends_on:

      - db

  db:

    image: mariadb:latest

    environment:

      MYSQL_ROOT_PASSWORD: example

      MYSQL_DATABASE: strapi

    volumes:

      - db_data:/var/lib/mysql

volumes:

  db_data:

使用步骤

1. 将上述内容保存为 docker-compose.yml 文件。

2. 在终端中运行 docker-compose up -d 启动服务。

3. 访问 http://localhost:1337,按照提示完成 Strapi 的初始设置。

2. KeystoneJS

Docker 支持:虽然 KeystoneJS 没有官方的 Docker 镜像,但您可以自己编写 Dockerfile,或者使用社区提供的镜像。

MariaDB 支持:通过 Prisma,KeystoneJS 可以连接到 MariaDB。

部署示例

由于需要自定义配置,建议参考以下步骤:

1. 编写 Dockerfile:创建一个包含您的 KeystoneJS 应用的 Dockerfile。

2. 编写 docker-compose.yml:配置 KeystoneJS 服务和 MariaDB 服务。

3. 运行:使用 docker-compose up -d 启动。

3. Payload CMS

Docker 支持:Payload CMS 提供了 Docker 部署指南。

MariaDB 支持:支持 MySQL 兼容的数据库,包括 MariaDB。

部署示例

version: '3'

services:

  payload:

    image: payloadcms/payload

    ports:

      - '3000:3000'

    environment:

      PAYLOAD_CONFIG_PATH: ./payload.config.js

      DATABASE_URL: mysql://root:example@db:3306/payload

    volumes:

      - ./:/usr/src/app

    depends_on:

      - db

  db:

    image: mariadb:latest

    environment:

      MYSQL_ROOT_PASSWORD: example

      MYSQL_DATABASE: payload

    volumes:

      - db_data:/var/lib/mysql

volumes:

  db_data:

4. Webiny

Docker 支持:Webiny 可以使用 Docker 部署,但主要设计为无服务器架构。

MariaDB 支持:需要进行额外的配置,可能不如其他选项直接。

推荐方案

鉴于您的需求,Strapi 是最直接和方便的选择。它提供了官方的 Docker 镜像,默认支持 MariaDB,而且社区资源丰富。

参考资料

Strapi 官方文档Strapi – Getting Started with Docker

Payload CMS Docker 指南Payload CMS – Docker Deployment

注意事项

环境变量:确保在 docker-compose.yml 中正确配置环境变量,如数据库用户名、密码和数据库名称。

数据持久化:使用 Docker 卷(volumes)来持久化数据库数据,防止数据在容器重启时丢失。

端口映射:确认服务的端口映射,避免与本地其他服务冲突。

我覺得wordpress的優勢在於 plugin很多,很多金流都有套件可以用…看來要找時間來試一下這些架站是否可以用來取代wordpress了

跑Scrum 或敏捷Agile於產品開發專案管理之心路歷程 初期到穩定期

跑Scrum 或敏捷Agile於產品開發專案管理之心路歷程 初期到穩定期

在產品開發的初期階段,Scrum 和敏捷的方法真的有必要嗎?這是個值得探討的問題。敏捷和 Scrum 在軟體業界很普遍,很多團隊透過看板、每日站會、短衝(Sprint)和回顧會議(Retro)來落實敏捷的理念。然而,這些形式化的流程能不能真的解決實際問題?特別是在突發需求頻繁出現的情況下,可能需要更靈活的處理方式。

初期階段的挑戰:探索與定義基礎

在從 0 到 1 的產品開發過程中,關鍵在於快速探索和實驗,收斂出「理想中的產品」——一個真正解決用戶痛點並具有長期價值的產品。這個階段的主要工作是設計規劃和開發執行,但產品還處於不穩定階段時,是否需要導入 Scrum 的周期性制度,其實需要根據實際情況來決定。

我認為,這個階段更需要有經驗的團隊成員來主導方向。他們要能掌握專案的核心,建立必要的開發規範,比如命名規則、測試流程、自動化測試和部署等等。這些規範是為未來的開發文化打基礎的,比形式化的敏捷流程更為重要。

核心目標:快速驗證與凝聚共識

在產品初期,快速驗證概念和想法是最重要的。我們需要團隊內部的高度共識,而不應該盲目執行敏捷框架裡的所有流程。

例如,每日站會是有價值的,因為它能幫助我們快速對齊進度和方向。但像定期的計劃會議或回顧會議,未必是必需的。開會的重點應該放在解決問題和確認目標,而不是為了執行某種固定的流程。

在實際操作中,我們可能花一整週時間設計和驗證基礎架構,而這些會議更像是靈活的討論,目的是確保團隊方向一致。無論是用什麼形式,只要能達到溝通和協作的效果就可以了。

成員特質與團隊目標

初期階段的成功很大程度上取決於團隊的特質和目標是否一致。這個時期的團隊需要具備創新和探索精神,所有人要能夠靈活應變並投入實驗。如果有成員無法融入這樣的節奏,可能會拖慢整個團隊。

產品穩定後:調整工作方式

當產品進入穩定期,比如 1.0 版本上線,開始有用戶使用,並且 CICD 流程已經成熟時,我們需要根據新的需求來調整工作模式。

穩定期的開發節奏會更加規律,我們可能引入更多的敏捷實踐,比如 Sprint 計劃、回顧會議和里程碑規劃等。這個時候,Scrum 的實踐方式會更有價值,因為穩定的節奏能幫助團隊同步開發進度和目標。

以下是我們實踐的一些要素:

1. 看板(Kanban):確保工作可視化,通常會追蹤兩個 Sprint 內的所有任務。我們使用 Excel 或簡單的工具來輔助管理,確保每個人都能鳥瞰全局。

2. 每日站會(Daily Standup):關注當前的障礙和即將進入看板的任務,解決問題更高效。

3. 計劃會議(Planning):對齊目標和分工,並確保每個成員理解需求。

4. 細化會議(Refinement):拆解功能並進行估算,確保大型需求能夠分階段完成。

5. 展示與回顧(Demo and Retro):每個 Sprint 結束時進行成果展示,前端、後端和文件更新都需要同步完成,確保所有人對進展有清楚認識。

6. 知識管理與文件同步:使用 Excel、Figma 或 Markdown 結合 Wiki 平台,記錄關鍵資訊,讓團隊成員能快速上手並保持靈活。

結論:形式為目標服務

敏捷和 Scrum 的核心是幫助團隊靈活應對變化並提升效率。在產品開發初期,重要的是設計適合當下情境的工作方式,而不是執著於敏捷的形式化流程。專注於快速驗證、團隊共識和基礎規範的建立,能讓團隊在不確定性中保持效率。

當產品進入穩定期後,可以逐步引入更多敏捷的元素,但前提是每項實踐都能真正為團隊和產品帶來價值。

透過Azure DataStudio / SSMS查詢效能不佳的SQL語法

透過Azure DataStudio / SSMS查詢效能不佳的SQL語法

這兩天遇到一個情境,一個早上突然所有的請求都卡住,平均回應 時間都要1~2分鐘。當下一時間沒啥方向,往登入那邊查,結果找錯了方向,會這樣想純粹是因為認為那個是一條測試的必經之路。而我們還在開發的資料庫中,最多也才幾百筆,最大 也不到一萬筆,為什麼會突然 這麼慢呢?

後來請 DBA看了一下後,馬上就找到問題了,大至記錄如下:

情境:
假如現在有兩張表
分別為
表 1 Master(Id)
表 2 Detail(Id, MasterId)

當時Master表大概有9xx筆,而Detail表有7千筆左右

select * from Master m
left join Detail d on m.Id = d.MasterId
where m.xxxxxx = xxxxxx

結果這樣不得了,一查發現大概執行個5、6百次 ,總執行時間就超過4小時以上了..
一般來說,PrimaryKey需設是Unique+Index(Cluster)因此只以Id作搜尋條件,效能不會差
但此時,Foreign Key若子表的Column沒有建Index,則對搜尋效能仍無影響,FK只作為 限制功能

補充以上觀點後

再補上SqlServer這次如何查找有問題的效能語法的方法(我是Mac,突然很羨慕ssms完整版功能)
不過我也立馬補上Azure Data Studio的方法,不無小補 一下。

1.Azure Portal對有問題的database進行 “查詢效能深入解析” (Azure Portal上的DB查詢權限要開放)

ex: 透過下列清單,可以查詢到Top5慢的查詢,透過查詢ID,可以再往 展開

註 : 這個畫面出不來的話代表 Azure Portal上對DB的權限不夠,不過至少可以對到查詢 ID,再請DBA或用 SSMS/Azure Data Studio去細追

2.透過SSMS,需要DBA針對db user進行開放

Query Store(查詢存放區)
-> Top Resource Consuming
-> Tracked Queries
-> QueryId

SSMS功能就是強大 ,可以針對 時間/CPU/IO統計

對於我們的查詢 ,在一個時間區間內,Where 條件不一樣 ,可能就QueryId就會不同(Plan Id就會不一樣)
-> ViewQueryTest查詢其問題語法分析 執行計劃

透過 查詢計劃可以看到發生了兩次Scan

3.透過Azure Data Studio,加入dashboard圖表

最後一種,若你是用Mac的環境(M1 Core),沒有辦法安裝SSMS這套工具的話

教學:https://learn.microsoft.com/en-us/azure-data-studio/tutorial-qds-sql-server

設定完重啟,在Database-> Manage就可以看到以下的圖表,也可以對齊上述方法的查詢Id

能看到非常 明顯 的峰點,那就還算是好的情境~(特別是還在 測試區)

針對該圖表 可以往下 看Insight的清單,可以對到1525就是問題查詢的所在

從query_sql_text就可以拿到問題語法,一樣貼到Azure Data Studio的Query窗

就可以Estimated Plan來顯示查詢計劃囉

DateTimeOffset在ToString()的神秘事件

DateTimeOffset在ToString()的神秘事件

最近早上08:xx多的時候,跑本機整測突然一個測試跑不過 。

下來Debug查說是比對值驗證 錯誤

測試情境是 datetimeoffset的value會存進資料庫,再查出來直接 做比對是否 正確

突然就這麼錯了覺得很怪,往下 追查,其比對的值出現了
2024/1/1 12:00:00 +00:00 vs 2024/1/1 00:00:00 +00:00

的情況,想說應該是24小時制的問題,因此寫了一小段程式驗證

        var valueHour12 = new DateTimeOffset(2024, 1, 1, 12, 0, 0, 0, offset: TimeSpan.FromHours(0));
        var displayToHour12 = new DateTimeOffset(2024, 1, 1, 0, 0, 0, 0, offset: TimeSpan.FromHours(0));
        
        var s = valueHour12.ToString("o");
        var sWithO = valueHour12.ToString();
        
        Console.WriteLine("valueHour12");
        Console.WriteLine(s);
        Console.WriteLine(sWithO);
        
        Console.WriteLine("displayToHour12");
        s = displayToHour12.ToString("o");
        sWithO = displayToHour12.ToString();
		
        object boxingHour12Am = displayToHour12;
        
        Console.WriteLine(displayToHour12.Hour);
        Console.WriteLine(s);
        Console.WriteLine(sWithO);
        Console.WriteLine(boxingHour12Am);
        //tostring 失真點
        var parsedDTOffset = DateTimeOffset.Parse(boxingHour12Am.ToString());
        Console.WriteLine(parsedDTOffset.Hour);
        
        //明確格式 不會失真
        parsedDTOffset = DateTimeOffset.ParseExact(displayToHour12.ToString("yyyy/MM/dd hh:mm:ss t z"), "yyyy/MM/dd hh:mm:ss t z", new CultureInfo("zh-TW"));
        Console.WriteLine(parsedDTOffset.Hour);

輸出結果如下,會發現指定12點的 跟指定0點的,在ToString()後,再解回Datetimeoffset的結果,hour會飄掉,具體來看是他現顯示成如下

試著把Console印出來結果如下

valueHour12
2024-01-01T12:00:00.0000000+00:00
2024/1/1 12:00:00 +00:00
displayToHour12
0
2024-01-01T00:00:00.0000000+00:00
2024/1/1 12:00:00 +00:00
2024/1/1 12:00:00 +00:00
12 //不明確格式解成了 12的數字
0 //明確格式可以正確解成0的Hour

這個知道為什麼後,解法很簡單,就是明確轉字串格式

不過 為什麼說神秘?

因為首先Windows的OS上不會有這個情況,ToString()至少會有PM, AM或上午, 下午,所以這個案例在Windows的PC上跑測試,目前看起來都 測不出來。目前是我是在Mac的Rider上測試,或許會有OS 的環境、語言差異,造成這個影響,不過我是從Rider上直接看到他的變數可視化 就是直接忽略了上午/下午,這個IDE上差異就比較特別,在Windows的Rider跟VS則都有上午/下午。IDE與OS之餘,可能也可以在Windows上安裝 Docker跑Linux看看,不過 若ToString()可以重現 0點變 成12的Hour屬性 ,就代表 非Windows系統就會有這種ToString()失真的情況!

第二個,只要過了 12點這個臨界點,例如台灣時間9點,代表 UTC凌晨1:00,再ToString(),就 不會有這個問題,可以解出 1的Hour,但是若是指定13點,就又會出現這個情況,一樣會解出1,分別會變成1點PM跟 13點,這樣的測試時間點若加上台灣時間的shifting(-8),很容易混淆,或是誤以為沒問題,且若固定 時間,或隨機時間導致測試測不出來,而不知哪天會有重大的狀況。

設定為 13的情境如下:

例如我們都設定 是 測試資料為 早上10點,那這個問題不會炸,不然就是我們設定 固定 CICD整測 跑的時間都是早上11點,那可能抓到的時間也都沒問題

因此需要小心程式中任何 地方 datetimeoffset,tostring(), 序列化 或 檔案化 的部分會不會有失真的情節 ,將其格式明確統一地定好。

而今天 因為9:00前上班被抓到而探討一下這個問題也實屬運氣成份吧?

在Azure AppService Docker Alpine Container上連線MSSQL遇到 網路不通的問題

在Azure AppService Docker Alpine Container上連線MSSQL遇到 網路不通的問題

最近使用AppService佈署Container服務

結果在連線資料庫的時候 一直掛掉

A network-related or instance-specific error occurred while establishing a connection to SQL Server.

Container的Linux版本是Alpine

因此安裝套件的指令是改用用apk add,不是apt-get

以後telnet套件無法直接安裝,而是被封裝進 busybox-extras 

Ref: https://github.com/gliderlabs/docker-alpine/issues/397

apk add busybox-extras

但實測Telnet下

app# busybox-extras telnet sea-dev-xxxx-sqlsrv.database.windows.net 1433

是通的,但程式還是連不過去

後來終於在這篇找到了一點蛛絲馬跡

https://github.com/dotnet/dotnet-docker/issues/2346

馬上試了一下
apk add icu-libs

果然通了,浪費了我二個小時XD

LeetCode 20大挑戰- 清單-置頂

LeetCode 20大挑戰- 清單-置頂

新增HackerRank筆記

  • New Year Chaos(Link)

Finished

  1. LRU Cache (Done) Link
    • List, dictionary, linked list
  2. Median of Two Sorted Arrays Link
    • Array merge, sorting
    • 困難的是如何達成O(log(m+n))
  3. Palindrome Number Link
    • string function
  4. Majority Element Link
  5. H-Index Link
  6. Two-Sum Link
    • HashTable
  7. Task Scheduler Link
  8. Three-Sum Link
    • Two Pointer
  9. Reverse Interger Link

Selected

  • Container With Most Water
    • Array, Two Pointer, Greedy
  • Reverse Nodes in k-Group(Hard) – Problem
    • Linked List, Recursion
  • Regular Expression Matching – Problem
    • String, Dynamic Programming, Recursion
  • Longest Substring Without Repeating Characters – Problem
    • String, hashtable , Sliding window
  • Substring with Concatenation of All Words
    • Sliding Window , string, HashTable
  • Minimum Path Sum
    • Array, DP, Matrix
  • Best Time to Buy and Sell Stock III (Best Time to Buy and Sell Stock IV)
    • Array, DP

LeetCode Median of Two Sorted Arrays

LeetCode Median of Two Sorted Arrays

看到Hard就來看看

Given two sorted arrays nums1 and nums2 of size m and n respectively, return the median of the two sorted arrays.

The overall run time complexity should be O(log (m+n)).

Example 1:

Input: nums1 = [1,3], nums2 = [2]
Output: 2.00000
Explanation: merged array = [1,2,3] and median is 2.

Example 2:

Input: nums1 = [1,2], nums2 = [3,4]
Output: 2.50000
Explanation: merged array = [1,2,3,4] and median is (2 + 3) / 2 = 2.5.

Constraints:

  • nums1.length == m
  • nums2.length == n
  • 0 <= m <= 1000
  • 0 <= n <= 1000
  • 1 <= m + n <= 2000
  • -106 <= nums1[i], nums2[i] <= 106
直覺寫法
public class Solution {
    public double FindMedianSortedArrays(int[] nums1, int[] nums2) {
                var merged = nums1.Concat(nums2);

        var mergedArray = merged.OrderBy(a => a).ToArray();
        
        if (mergedArray.Length % 2 > 0)
        {
            var median = mergedArray[(mergedArray.Length - 1) / 2];
            return median;
        }
        else
        {
            var medianFirst = mergedArray[((mergedArray.Length) / 2)-1];
            var medianLast = mergedArray[((mergedArray.Length) / 2)];
            return ((double)medianFirst + (double)medianLast) / 2;
        }
    }
}

結果就這樣過了,到底為什麼是Hard?

看了某些人也是這樣寫
Complexity:
Time complexity: The time complexity is dominated by the sorting operation, which takes O(N log N) time, where N is the total number of elements in nums1 and nums2.
Space complexity: The space complexity is determined by the size of the combined list nums, which is O(N), where N is the total number of elements in nums1 and nums2.

public class Solution {
    public double FindMedianSortedArrays(int[] nums1, int[] nums2) {
         List<int> nums = nums1.ToList();
            nums.AddRange(nums2.ToList());
            nums.Sort();
            if(nums.Count % 2 == 1)
                return nums[nums.Count/2];

            return (nums[nums.Count/2] + nums[nums.Count/2 - 1])/2.0;
    }
}

所以這題的難度感覺是如何 說明其時間複雜度吧XD

終於為部落格加上SSL設定(劇情急轉直下)

終於為部落格加上SSL設定(劇情急轉直下)

我的網站在http的世界中許久 ,這陣子終於想來弄SSL環境了(因為 老婆要寫網址XD) 先前我記得是卡在PH …

以上就是庫存頁檔救回的一小段字 XD

無意見發現多年前設定的mariadb,密碼一點都沒有安全性,結果就被破解了

苦腦許久,發現我在 6月初有備份image,至少還救的回2022年以前的文章 Orz

目前我先 救到這邊…

後續故事跟被入侵綁架的故事再補上….

以下是phpmyadmin被瘋逛try 密碼的logs(從docker logs來的)

資料庫還給我留下警語

.NetCore的ApiGateway +Consul的本機實驗

.NetCore的ApiGateway +Consul的本機實驗

一直以來都知道.netcore可以開始跨平台運作在linux上,因此運作在 docker的container中也是可行的

不過最近因為要研究apigateway,需要模擬infra的架構 ,因此需要在本機環境buildup起來多台主機的模式

沒想到入坑玩了一下,就發現,其實眉眉角角也不少,但來來回回操作後,其實是相對會愈來愈熟悉

首頁,我solution的構成包含了以下幾層 (apigateway還可做loadbalance,再加個nginx就可以, 不是這次的重點)

1.Redis(多台 instance作counter的cache)
2.WebApp(.net core)
3.ApiGateway(.net core + ocelot.net sdk)
4.Consul(service discovery service 作ha)

這次主要研究的機制為 3跟4之間的協作機制
基本上,有了3,預設走configuration就可以手動配置 ,但若要能做到動態ha的話,就必須加入service discovery的機制,剛好ocelot框架有支援 consul(還有其他套,但預設consul)

docker-compose

為了方便 重建,這次採用 docker-compose的yaml來維護(以下情境的container -name 都一般化了,只需要區分角色就好)

version: "3"

services:
  nginx:
    build: ./nginx
    container_name: core-app.local
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - core-app-gateway1
    # network_mode: "host"
    networks:
      - backend

  consul1:
    image: consul
    container_name: node1
    command: agent -server -bootstrap-expect=2 -node=node1 -bind=0.0.0.0 -client=0.0.0.0 -datacenter=dc1
    # network_mode: "host"
    networks:
      - backend

  consul2:
    image: consul
    container_name: node2
    command: agent -server -retry-join=node1 -node=node2 -bind=0.0.0.0 -client=0.0.0.0 -datacenter=dc1
    depends_on:
      - consul1
    # network_mode: "host"
    networks:
      - backend

  consul3:
    image: consul
    hostname: discover-center
    container_name: node3
    command: agent -retry-join=node1 -node=node3 -bind=0.0.0.0 -client=0.0.0.0 -datacenter=dc1 -ui
    ports:
      - 8500:8500
    depends_on:
      - consul1
      - consul2
    #network_mode: "host"
    networks:
      - backend

  redis:
    container_name: myredis
    image: redis:6.0.6
    restart: always
    ports:
      - 6379:6379
    privileged: true
    command: redis-server /etc/redis/redis.conf --appendonly yes
    volumes:
      - $PWD/data:/data
      - $PWD/conf/redis.conf:/etc/redis/redis.conf
    networks:
      - backend

  core-app-discover-a1:
    build: ./dotnetapp/dotnetapp-discover
    hostname: webapp1
    environment:
      - HOSTNAME=http://0.0.0.0:40001
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://core-app-discover-a1
      - CLIENT_PORT=40001
      - SERVICE_NAME=core-app-discover-a
      - RedisConnection=redis:6379,password=12345678,allowAdmin=true
    depends_on:
      - redis
      - consul3
    ports:
      - "40001"
    networks:
      - backend

  core-app-discover-a2:
    build: ./dotnetapp/dotnetapp-discover
    hostname: webapp2
    environment:
      - HOSTNAME=http://0.0.0.0:40001
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://core-app-discover-a2
      - CLIENT_PORT=40001
      - SERVICE_NAME=core-app-discover-a
      - RedisConnection=redis:6379,password=12345678,allowAdmin=true
    depends_on:
      - redis
      - consul3
    ports:
      - "40001"
    networks:
      - backend

  core-app-discover-a3:
    build: ./dotnetapp/dotnetapp-discover
    hostname: webapp3
    environment:
      - HOSTNAME=http://0.0.0.0:40001
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://core-app-discover-a3
      - CLIENT_PORT=40001
      - SERVICE_NAME=core-app-discover-a
      - RedisConnection=redis:6379,password=12345678,allowAdmin=true
    depends_on:
      - redis
      - consul3
    ports:
      - "40001"
    networks:
      - backend

  core-app-discover-b:
    build: ./dotnetapp/dotnetapp-discover
    hostname: webapp-b
    environment:
      - HOSTNAME=http://0.0.0.0:50001
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://core-app-discover-b
      - CLIENT_PORT=50001
      - SERVICE_NAME=core-app-discover-b
      - RedisConnection=192.168.1.115:6379,password=xxxxxxx
    depends_on:
      - redis
      - consul3
    ports:
      - "50001:50001"
    networks:
      - backend

  core-app-discover-c:
    build: ./dotnetapp/dotnetapp-discover
    hostname: webapp-c
    environment:
      - HOSTNAME=http://0.0.0.0:50002
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://core-app-discover-c
      - CLIENT_PORT=50002
      - SERVICE_NAME=core-app-discover-c
      - RedisConnection=192.168.1.115:6379,password=xxxxxxx
    depends_on:
      - redis
      - consul3
    ports:
      - "50002:50002"
    networks:
      - backend

  core-app-gateway1:
    build: ./dotnetapp/dotnetapp-gateway
    hostname: gateway1
    environment:
      - HOSTNAME=http://0.0.0.0:20001
      - SERVER_HOSTNAME=http://192.168.1.115
      - SERVER_PORT=8500
      - CLIENT_HOSTNAME=http://192.168.1.115
      - CLIENT_PORT=20001
      - SERVICE_NAME=core-app-gateway1
    depends_on:
      - consul3
    ports:
      - 20001:20001
    networks:
      - backend

networks:
  backend:
    driver: bridge

以上yaml就會建立一個network讓 container之前可以用 hostname來通訊。

這次研究的過程,會發現一件事情就是若要綁service discovery,那麼每個末端的webapp都需要跟register center做註冊,並提供一個health接口。但是若要做到auto scale,那依賴docker-compose所做的scale就會綁定動態的host_name(有序號),因此在此不建議 用scale直接長n台容器,而是先用多個端點來建置(如我的a1、a2、a3三台)
這樣就可以指定 hostname跟port給 webapp,而不會被scaling的機制而無法抓到動態的port跟名稱

.netcore application的Dockerfile

以下是webapp的dockerfile (gateway的打包方式差不多,就不貼了)

FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build
WORKDIR /src
COPY ["dotnetapp-discover.csproj", "dotnetapp-discover/"]
RUN dotnet restore "dotnetapp-discover/dotnetapp-discover.csproj"
COPY . ./dotnetapp-discover
WORKDIR "/src/dotnetapp-discover"
RUN dotnet build "dotnetapp-discover.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "dotnetapp-discover.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "dotnetapp-discover.dll"]

為了計數器功能與多台sharing cache,因此這次 還 模擬了一下連redis
而redis的connection string 可以透過 environment variable 注入進來(docker-compose可以另外用.env的檔案來管理不同環境,有興趣可以看 官方文件),而在.netcore的c#程式中,要抓到docker的參數的話可以使用GetEnvironmentVariable來取得設定值

    public static IServiceCollection AddCacheService(this IServiceCollection services)
    {
        var redisConnection = Environment.GetEnvironmentVariable("RedisConnection");
#if DEBUG
        redisConnection = "192.168.1.115:6379,password=xxxxxx,allowAdmin=true";
#endif
        
        services.AddSingleton<IConnectionMultiplexer>(
            ConnectionMultiplexer.Connect(redisConnection)
        );
        return services;
    }

註∶為了反覆測試,才加上allow admin,不然這個開關是很危險的,可以直接flush掉你的所有快取(清空)

Redis記錄的Log型態

註冊Service Discovery

ApiGateway跟WebApp我都有跟Consul註冊Service Discovery
因此這次主要的寫法也是follow consul client sdk的套件,Consul的註冊管理上,同一個Name的會被Grouping起來,ID要給不一樣的,若ServiceName都不一樣,就算是一樣的程式,還是會被視為不同節點,這樣會無法作HA的判斷

static void RegisterConsul(string serviceName, string serverHostName , string serverPort, string clientHostName , string clientPort, IHostApplicationLifetime appLifeTime)
{
    using var consulClient = new Consul.ConsulClient(p => 
        { p.Address = new Uri($"{serverHostName}:{serverPort}"); });
    var registration = new AgentServiceRegistration()
    {
        Check = new AgentServiceCheck()
        {
            Interval = TimeSpan.FromSeconds(10),
            HTTP = $"{clientHostName}{(clientPort == string.Empty ? "" : $":{clientPort}")}/health",
            Timeout = TimeSpan.FromSeconds(5),
            Method = "GET",
            Name = "check",
        },
        ID = Guid.NewGuid().ToString(),
        Name = serviceName,
        Address = Dns.GetHostName(),
        Port = int.Parse(clientPort),
        
    };

    try
    {
        consulClient.Agent.ServiceRegister(registration).Wait();
    }
    catch (Exception e)
    {
        Console.WriteLine(e);
    }

    //web服務停止時,向Consul解註冊
    consulClient.Agent.ServiceDeregister(registration.ID).Wait();
}

以上function可以在program.cs的startup時期先進行註冊,而這個function所有的參數一樣可以來自於 docker-compose
範例∶

自動生成HealthCheck端點

在webapp的startup可以使用以下語法就長出health接口

builder.Services.AddHealthChecks();

app.MapHealthChecks("/health",  new HealthCheckOptions()
{
    ResultStatusCodes =
    {
        [Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Healthy] = StatusCodes.Status200OK,
        [Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Degraded] = StatusCodes.Status200OK,
        [Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable
    }
});

但還是要視服務加上MapHealthChecks的ResultStatus的設定,以consul來說,就是回200就好了,若沒有加上這個設定,其實health的回應並不是200,所以會被視為失敗

正常情況打 health的接口

Ocelot的設定檔

最後就是ocelot的json設定,傳統的Ocelot自已就有Load-Balance機制,因此若有多台Downstream的服務,本來就可以用以下設定去管理服務

不過這樣做的缺點就是若隨時要增減 容器/主機時,都要再去重新配置設定檔。造成主機可能的reset與維運上的難度,因此透過Service Discovery的好處就浮上了臺面!以下的配置,只需要注意,不用定義DownStream的Host,只要給一個ServiceName,這個ServiceName要跟WebApp在Startup的時候註冊的一樣。並在最後GloablConfiguration有註冊Service DiscoveryProvider的設定,就可以打通ApiGateway跟Service Discovery之間的便利機制。

{
  "Routes": [
    {
      "DownstreamPathTemplate": "/test",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/newapi/get",
      "UpstreamHttpMethod": [
        "Get"
      ],
      "ServiceName": "core-app-discover-a",
      "LoadBalancerOptions": {
        "Type": "LeastConnection"
      }
    },
    {
      "DownstreamPathTemplate": "/test/list",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/newapi/list",
      "UpstreamHttpMethod": [
        "Get"
      ],
      "ServiceName": "core-app-discover-a",
      "LoadBalancerOptions": {
        "Type": "LeastConnection"
      }
    },
    {
      "DownstreamPathTemplate": "/test",
      "DownstreamScheme": "http",
      "DownstreamHttpMethod": "DELETE",
      "UpstreamPathTemplate": "/newapi/reset",
      "UpstreamHttpMethod": [
        "Get"
      ],
      "ServiceName": "core-app-discover-a"
    }, 
    {
      "Key": "api1",
      "DownstreamPathTemplate": "/test/list",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/newapi/list1",
      "UpstreamHttpMethod": [
        "Get"
      ],
      "ServiceName": "core-app-discover-a"
    },
    {
      "Key": "api2",
      "DownstreamPathTemplate": "/test/list",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/newapi/list2",
      "UpstreamHttpMethod": [
        "Get"
      ],
      "ServiceName": "core-app-discover-a"
    }
  ],
  "Aggregates": [
    {
      "RouteKeys": [
        "api1",
        "api2"
      ],
      "UpstreamPathTemplate": "/newapi/listall"
    }
  ],
  "GlobalConfiguration": {
    "BaseUrl": "http://0.0.0.0:20001",
    "ServiceDiscoveryProvider": {
      "Scheme": "http",
      "Host": "192.168.1.115",
      "Port": 8500,
      "Type": "PollConsul",
      "PollingInterval": 100
    },
    "QoSOptions": {
      "ExceptionsAllowedBeforeBreaking": 0,
      "DurationOfBreak": 0,
      "TimeoutValue": 0
    },
    "LoadBalancerOptions": {
      "Type": "LeastConnection",
      "Key": null,
      "Expiry": 0
    },
    "DownstreamScheme": "http",
    "HttpHandlerOptions": {
      "AllowAutoRedirect": false,
      "UseCookieContainer": false,
      "UseTracing": false
    }
  }
}

上述的ApiGateway一樣用DockerFile把專案包成Image

整合測試

docker-compose up -d –force-recreate

會看到一次起來這麼多服務

http://localhost:20001/newapi/get(透過api gateway打到downstream的service)

這邊多請求幾次會發現machineName一直在webapp1~3跳來跑去,採用load-balance的機制,ocelot會採用你設定的策略進行請求分派

ApiGateway有Aggregation的功能,就是可以把多個Api請求Combine在一起,讓請求變成1次,減少頻寬的消耗,這邊我也做了配置,來試看看吧

出於Ocelot.json的設定檔段落:

透過api gateway的組合讓 多個 Api可以一次 回傳

最後,打了很多次請求後,來總覽一下所有的請求摘要(程式是自已另外撈Redis的Request記錄來統計)

http://localhost:20001/newapi/list(透過api gateway打到down stream的service)

嘗試Docker Stop某一台 Discover-a1的服務,Consul DashBoard馬上就看的到紅點了!

這個時候再去打 之前load-balance的端點http://localhost:20001/newapi/get,會發現machineName會避開stop掉的服務只剩下 webapp1跟webapp3在輪循,因此做到了後端服務品質的保護

小插曲

ID不小心用成了Guid.New,所以會導致重新啟動就又拿到新的ID,造成了多一個Instance。但舊的也活了,因為他們的HealthCheck是一樣的

其實ApiGateway的議題,從Infra的角度上,可以玩的東西還有很多。

像是他也支援RateLimiting, QualityOfService(Polly) , 服務聚合(Aggregation)等機制,都可以從他們的官方文件看到設定方式,算是配置上不算太複雜。

不過 Ocelot在.net這邊發展一陣子後,近一年好像停更了!取而代之的好像是其他語言寫的更強的Apigateway: Envoy。

Envoy資源
https://www.envoyproxy.io/docs/envoy/latest/intro/what_is_envoy
https://www.netfos.com.tw/Projects/netfos/pages/solution/plan/20210111_plan.html

若只是要輕量級的,或是想參考其架構設計的,倒是可以翻github出來看看~

Github∶ https://github.com/ThreeMammals/Ocelot

這次Lab程式碼

若要拿去玩的,請把Ip, Password自行調整一下

一些文章的參考

https://ocelot.readthedocs.io/en/latest/features/servicediscovery.html?highlight=ServiceDiscoveryProvider

https://ocelot.readthedocs.io/en/latest/features/servicediscovery.html?highlight=ServiceDiscoveryProvider#

https://www.uj5u.com/net/232249.html