0%

I’m curious how cache-control directives affect caching, so I made a small experiment to see the interaction between browser, reverse proxy and server.

First of all, I introduced 6 APIs that respond different cache-control value in response header and current time only.

Reponse Cache-Control
[GET]/cache/get-public public,max-age=120
[GET]/cache/get-private private,max-age=120
[GET]/cache/get-unset max-age=120
[DELETE]/cache/delete-public public,max-age=120
[DELETE]/cache/delete-private private,max-age=120
[DELETE]/cache/delete-unset max-age=120

Then I created a simple web page for test
/images/20221103/0.png

By clicking the buttons, APIs were called and showed the response data on page. Meanwhile, I monitored the developer tool and record the results.

The results of browsers reaction. (on safari, chrome and edge)

  • (v) : cached
  • (X) : none-cached
Http Method \ Cache Directive Public Private Unset
GET V V V
DELETE X X X

The results of reverse proxy reaction.

Http Method \ Cache Directive Public Private Unset
GET V X X
DELETE X X X

According to the results, we know that the browsers would not break the rule of cacheable, and reverse proxy may not store the response when you don’t set public/private directives.

Recently I found that the HttpClient doesn’t change cookie value per request, and it will cache the value for 2 minutes after the first request which have set the cookie value sent.

Environment

  • dotnet 5
  • runtime image: mcr.microsoft.com/dotnet/aspnet:5.0

Description

I use typed client in our project and use HttpRequestMessage to set the cookie “user.token” in every request.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class MyHttpClient : IMyHttpClient
{
private readonly HttpClient _httpClient;

public MyHttpClient(HttpClient httpClient)
{
this._httpClient = httpClient;
}

public async Task CallAPI()
{
...

var request = new HttpRequestMessage(HttpMethod.Post, "/the/api");
// set request content
request.Content = new StringContent("mydata", Encoding.UTF8, "application/json");
// set cookie here
request.Headers.Add("Cookie", $"user.token={token}");

var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var res = await response.Content.ReadAsStringAsync();

...
}
}

Register MyHttpClient to the Denpency Injection provider.

1
2
3
4
5
services.AddHttpClient<IMyHttpClient, MyHttpClient>(config =>
{
config.BaseAddress = new Uri("https://my.server.com");
});

When I use CallAPI() of MyHttpClient, I found the cookie doesn’t change value per request, and it will cache value for 2 minutes.

Why

HttpClient is just a container of HttpClientHandler. If you trace the source code on Github you will see the code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public HttpClient CreateClient(string name)
{
if (name == null)
{
throw new ArgumentNullException(nameof(name));
}

var handler = CreateHandler(name);
var client = new HttpClient(handler, disposeHandler: false);

var options = _optionsMonitor.Get(name);
for (var i = 0; i < options.HttpClientActions.Count; i++)
{
options.HttpClientActions[i](client);
}

return client;
}

public HttpMessageHandler CreateHandler(string name)
{
if (name == null)
{
throw new ArgumentNullException(nameof(name));
}

var entry = _activeHandlers.GetOrAdd(name, _entryFactory).Value;

StartHandlerEntryTimer(entry);

return entry.Handler;
}

When you are asking for HttpClient, HttpClientFactory will generate a HttpClient instance everytime and try to get HttpMessageHandler from the pool. Why HttpClientFactory maintains HttpMessageHandler pool for us? Because creating TCP connections are extremely expensive, so we should reuse it as we can as possible.

So multiple HttpClients will possibly use same HttpMessageHandler. HttpMessageHandler has Property named CookieContainer. It will store cookie value when you first time to set the value. So it will cause different requests using same cookie value until HttpMessageHandler expired.

Solution

There is a way to stop HttpMessageHandler using CookieContainer.

1
2
3
4
5
6
7
8
9
services.AddHttpClient<IPortalShellClient, PortalShellHttpClient>(config =>
{
config.BaseAddress = new Uri(clientConfig.PortalSiteEndpoint);
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
//Tell HttpMessageHandler to stop using CookieContainer
return new HttpClientHandler { UseCookies = false };
});

Then you can set cookie value per request now.

References

建立共同語言

這章主是在講述語言的重要性,而語言又如何直接間接的影響到系統的成敗。這邊的語言並非指程式語言,而是指在相同情境下大家所共同理解的語言,就用 User 這個詞來舉例,在不同情境下就可能代表著不同意義

  • 對購物網站而言 : User 可能代表的是逛商城的消費者
  • 對商城平台而言 : User 可能代表是進駐的廠商
  • 對商城後台而言 : User 可能代表的是進駐廠商的員工

一個 User 在各個情境下代表著不同意義,想關注的事情也不盡相同,當我們沒有情境為背景下,單單用 User 做為溝通可能會陷入雞同鴨講的狀況,這就是情境之於語言為何重要的地方。

過去開會時發現一個現象,時常開發單位與需求單位在溝通時,彼此對於同一個東西用的詞是完全不同的,當需求單位在描述事情時,較新進或對 Domain 不熟的開發人員可能會弄不清楚對方在說什麼,這時候就需要熟的人在旁邊補充「他指的是系統裡的 xxx」,而會產生這樣的差距,常常是因為開發人員將他們的工作視為”將需求翻譯成程式語言”,所以程式內的語言可能自成一格,並與現實用語脫鉤,而這類的情況一多,當不同背景的人共同溝通時就會需要彼此一直翻譯,翻譯的狀況越多,中間遺失掉的”知識”可能就越多,大家應該都有看過翻譯很爛的書的經驗吧…

所以書中建議為不同背景的人找尋共同用語相當重要,這可以減少溝通時因為翻譯而導致的落差,書中舉了一個很真實的案例:當使用者在廣告系統中發佈 (publish) 廣告時,為了避免帶有惡意或不適當的內容,會讓該廣告進入審核的流程 ….,而當這個需求轉化為程式語言後可能會變成

  • 當使用者按下 publish 按鈕時,將廣告狀態更新為 Published,並且發出廣告狀態已變更(AdStatusUpdated)的事件
  • 審核系統收到 廣告狀態已變更(AdStatusUpdated) 事件時將廣告撈出來排入審核流程

看出這中間有多大的落差了嗎?我們在系統中不用 廣告已被發佈(AdPublished),取而代之的是廣告狀態已變更(AdStatusUpdated),發佈的動作轉化成狀態的變更,需求在這大量轉化為系統程式碼的過程中,很有可能原始的意圖早已消失,而這可能正是關鍵知識遺失的過程。

精準需求的迷失

每當我們交付功能給客戶,面對客戶的不滿意時,常常歸因於需求描述的失準,開發團隊內會互相抱怨、指責,開發者埋怨開需求的人規格亂寫,開需求的人埋怨開發者搞不清楚也不問,導致開發出來的系統與最終想解決的問題有一大段落差,所以團隊可能會開始找更厲害的 PO 或 PM,開始要求規格書的格式,試圖用鉅細彌遺厚厚一本的規格書來彌補與真實之間的落差。

而事實真的是這樣嗎?很多時候開發人員是沒有機會直接面對需求單位或業務人員的,所以當這些需求單位透過與系統分析師描述後,再透過系統分析師的理解轉化成規格書,這中間可能就已經遺失了一部分真實。

很多時候會議內所謂的共識,是你以為你的共識,我以為我的共識,實際上彼此認知的事情根本不是一回事,等雙方都告一段落後回來一對才發現跟當時說的不一樣,但彼此都覺得自己是照當時的決議實作的啊,所以問題可能不是雙方背信,而是彼此認知根本不在同一個點上。

書中提到可以嘗試在溝通中,用視覺化的方式彌平彼此認知的落差,而我自己的經驗也發現,大量的文字描述常常聽者很難聚焦,當開始把各個會議中談到的東西視覺化,輔助上依賴關係與線性流程,有助於幫助與會人員更能聚焦在同一個點上。
/images/20220525/1.jpg
圖片來源: 領域驅動設計與 .Net Core 書中 p.26

透過新的術語發現新的情境

最前面提到在不同情境下,User 可能代表的意義完全不同,當我們在討論過程中可能會脫口而出前台的消費者平台的廠商後台的管理者…等等,而當這些用語的不同時,其實也幫助了我們發現了新的情境,針對這些新出現的情境建立對應的模型是需要的,對比於前面只用 使用者(User)來描述,後者更精準也更貼近真實,這也可以避免開發者寫出近乎於上帝類別的 User 來。

雖然短短一小篇,但發現要把讀完的東西用自己的話再說一次真的還有一段差距啊

/images/20220523/cover.jpg
圖片來源: https://www.tenlong.com.tw/products/9789864348602

第一章講述了軟體發展史中,為了解決軟體開發上的困境許多方法被發明了出來,而雖然每個方法都宣稱能有效解決(或部分解決)專案上的問題,但經過統計,成功的專案始終不超過 22%。而 DDD 也是為了解決軟體開發時所碰到的困境所被提出來的一種方法。

軟體通常是為了解決某些問題而被研發的,所以當錯誤的理解問題就很容易導致失敗,人類在解決問題上有著豐富的經驗,所以當人類看到問題時,通常會有一些直覺的解決方案浮出腦中(快思慢想也有提到這點),這可能源自於你的經驗或是過往學經歷,而這直覺的好點子很高的可能是錯誤的方向,而人往往又會傾向於找盡各種方法試圖說服自己剛剛的好點子是正確的,即便在實作的過程中隱約察覺到不對勁了…

個人經驗中有察覺到這個現象,常常誤以為自己一開始的想法是正確的,所以即便過程中發現問題也會用各種變形方法試圖將問題套入其中,而這往往會將問題變得更複雜。

我曾經跟專案成員討論目前框架使用上的困境,而我發現大部分人會傾向在框架中設計各種 Config 、環境變數切換來滿足所謂的特殊情境,而沒有去思考框架設計是否瞄準錯了問題。又或是為了解決部署環境問題,直接聯想到 Container 解決方案,結果為了管理 Container 所以引入更複雜的 k8s 做為編排的工具,也許本質想解決的問題沒這麼複雜,卻在解決的過程中意外的把問題變得更複雜。

避免無知

書本中將無知分類成五個層級

  • 缺少無知(lack of ignorance) : 表示你擁有大部分的知識,知道「該做什麼」、「怎麼做」
  • 缺乏知識(lack of knowledge) : 你意識到自己的無知,所以你獲取更多知識來填補知識的落差
  • 缺乏意識(lack of awareness) : 「不知道自己不知道」,連意識到無知都沒有,例如拿到規格就覺得自己知道要解決的問題是什麼了,而忽略了其實自己根本不知道本質問題是什麼狀況,人往往會做出錯誤決策都屬這層
  • 缺乏流程(lack of process) : 你不知道你自己不知道是什麼,也無管道能弄清楚
  • 元無知(meta igonorance) : 連無知的五個層次都不知道

DDD 之父 Eric Evans 對於「提前設計」提出了見解 : 我們在專案的初期會進行提前設計的動作,而此時恰好也是我們擁有「最少知識」並且是最無知的時期。在專案開始時幾乎沒有知識的情況下,就對軟體的設計和架構做出大多數的「重要決策」,這不會是個好的做法。

個人覺得這跟敏捷中提到的小增量多迭代道理是一樣的,我們很難在專案初期就了解問題的全貌,所以小步前進,並且因應碰到的問題即時快速的調整,才能讓我們越來越貼近真實情況的樣貌,也可以降低在專案初期的過度設計,導致架構一直調整的窘境。

隨著現代專案要處理的問題越來越複雜(可能是質或是量的複雜度),有效的處理領域知識減少無知,準確將問題分類,避免實現目標路途中的認知偏誤,DDD 就是想要解決這些問題所被提出的。

近期很後悔自己中斷了寫部落格的習慣,為了重新養成這個習慣,所以有了這開春第一篇文章(都快夏天了老闆~),而既然是開春第一篇,想說就來寫些較軟性的題目好了(絕對不是我技術都沒長進的關係啦…),所以今天就來聊聊近期與團隊共患難的事情好了。

可能老闆知道我今年犯太歲的關係,不能讓我太好過,所以年初就派我去擔任一個 5 人小團隊的 tech lead,一開始本來懷抱著 “大家都很優秀,我應該只要去幫大家打打雜就好”的心態加入,殊不知考驗才剛剛開始。

這個團隊負責的是一個架構與開發框架性質的專案,亦即它的設計未來可能會直接影響 N 個團隊的開發習慣與 N 個服務掛在上面的行為,所以它的難度不低,加上初期時程壓力、人力吃緊的情況下,體質並不是很好,例如:

  • 專案幾乎沒有測試,上線常常發生改 A 壞 B 的情況
  • 文件非常多,但跟實際系統運作邏輯有不小的落差
  • 系統在還不穩固的情況下已經釋出 SDK 給團隊使用,加大改版向下相容的挑戰
  • Release 純手工,過程中需要人工介入調整許多地方,不熟的人看文件還要花半天以上才能搞定
  • 框架本身的目標與使用該框架的服務需求間的拉扯,導致初期就有高度客製化的狀況,增加了後續入住這個框架得複雜度
  • 資料庫版控管理不佳,每次更版幾乎都要倒資料,間接也讓服務開不出 API,因為實時的資料異動會導致資料遺失

基於上述原因,團隊士氣其實並不高,因為即便是看似簡單的功能上線都如履薄冰,加上該專案是受到重視的專案(被定位為乘載公司未來性的框架),人員陸續補進來,團隊的壓力也隨之增加,因為人力往往與產值掛鉤,給予對應的人力沒有對應的產值是無法被接受的。

所以初期我花最多時間思考的不是怎麼滿足需求,反而是該如何穩住系統跟增加開發成員的信心,在軟體業也快 10 年了,新需求與改需求是不會有停止的一天,每個跟你談的需求都會很急很趕,不急不趕才是新聞,所以與其追著需求跑,我更在思考的是

  1. 該如何穩住這個系統 ? 讓開發人員有信心,讓新進人員能在最短時間成為戰力
  2. 這個框架到底想解決的本質問題是什麼? 如果它沒有守住那條線,這個框架基本上也沒有存在的價值

所以我跟主管談了一個方案,農曆年前約三週時間需求我一律不收也不處理,一切請他幫我緩到農曆年後再說,對內開始跟團隊成員著手處理以下問題

  1. 重建系統核心
    我開始與團隊成員梳理目前系統運作的脈絡,對我來說系統要在不斷變動的環境下發展,不外乎讓你的程式碼能好好說明系統在做什麼,如果連幾個類別物件間的互動關係都說不清楚寫不好,又該如何期待這成千上萬行的程式碼堆疊出來系統會有一套清晰的邏輯在運作。又如何期待新進工程師能在眾多的文件中自己理出系統邏輯,縱然文件全然正確,理解文件後對應到程式碼的運作,這中間還有一段很長的路要走。所以我很喜歡 Uncle Bob 說的『世界上最好的文件應該是你的程式碼。』
    這個觀念剛好也契合 DDD 中所強調的,把你的專注力先放在解決核心問題上,把問題之外的資料庫、 Schema、 軟體分層、作業系統先切開,先用最簡單的類別與物件來展示你要解決的業務邏輯,當這些都說得通、運作得當,剩下的都是支撐這個核心的基礎建設而已。
  1. 建立單元測試
    我與團隊成員立下的第一個約定就是 “MR 只要沒有單元測試一律退回”,我認為確保寫出來的程式運作邏輯的正確性是身為工程師的基本素養,Code Review 應該是讓大家討論與互相學習的場域,並試著透過多人 Review 從不同角度尋找盲點,防止重大舞弊、效能、資安…等問題,不要期待 Reviewer 能用肉眼幫你找出所有漏洞,如果寫單元測試都無法保證系統百分之百正確了,更何況不寫。
    寫單元測試好處真的很多,除了能用最低成本快速驗證你的想法與程式碼之外,它也在保護你的團隊成員,當專案龐大到一個程度,團隊內成員對於 Code 與各個需求掌握度也都會有落差,所以當未來需求變動時,它能幫忙把關是否哪邊因為變動而出錯了。
  1. 重新梳理開發流程與 CI/CD
    與團隊成員討論了之前的分支策略後利弊後,我們開始改成 trunk-based 的跑法,試著降低大家解衝突的時間,並逼大家思考持續整合回 Master 的相容性問題,同時也開始投入資源建立 CI/CD ,讓一些明確與重複的流程自動化,降低部署的時間與負擔。穩固快速的部署流程不單單只是減少時間的浪費,往往也能增加團隊成員的信心,你會有信心即便真的最後出錯了,我們都有機會在很短的時間內迭代修復或退版,就像打籃球時有個超強中鋒幫你搶籃板一樣,各個投三分都跟 Curry 一樣有信心 (最近都在看 NBA 季後賽啦)。

經過一番奮戰後,我們終於在總共花了 4 週多的時間,將原本幾乎開不出 API 的系統,陸續釋出 20多支的 API 上線,並且交付批次工具幫助協作單位整合資料,解決了之前常常需要手動進資料庫塞資料與髒資料橫行的問題,也終於終於得到了團隊成員的第一次肯定(淚)。

但人生往往不會這麼簡單,你懂的 …

待續 …

前言

之前專案就使用過 CQRS 做為系統的架構來開發,但一直覺得自己『知其然,而不知其所以然』,剛好最近出了一本CQRS 命令查詢職責分離模式 所以就買回來把他讀完了,趁記憶還很清晰時趕快做一下筆記,以下的內容參雜了許多自己的見解,歡迎大家一起討論

什麼是 CQRS

CQRS 全名為 Command Query Responsibility Segregation ,意即命令查詢分離的設計模式,而

  • Command : 會對系統狀態或資料做出異動的行為
  • Query : 單純取得資料不會對系統狀態造成異動的行為

以傳統 CRUD 類比的話,CUD = Command,R = Query

為什麼需要 CQRS

降低複雜度

我自己接觸過的大部分系統,大多都是以維護資料做為出發點來設計的系統,例如專案內一定會有所謂的 Repository Layer : 一個提供各種 API 並能對資料做 CRUD 的封裝層。
系統本來就是一系列對資料維護的過程,這樣做有錯嗎? 只要商業情境不要太複雜的話,大部分都沒什麼問題,但如果系統規模越做越大,商業情境越來越複雜,如果只是用 CRUD 的思維來設計系統,往往會讓複雜度越疊越高,甚至到不可收拾的地步。
舉個例子,你應該看過這樣的 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void Update(MemberDto member)
{
var memberEntity = MemberRepository.Get(member.Id);
if(memberEntity == null)
throw new NullReferenceException("Member not found.");

if(string.IsNullOrEmpty(member.Name))
throw new ArgumentNullException(nameof(member.Name));

if(DateTime.TryParse(member.Birthday, out var birthday) == false)
throw new ArgumentException(nameof(member.Birthday));

//bla bla bla ....

if(string.IsNullOrEmpty(member.Password))
throw new ArgumentNullException(nameof(member.Password));

// Set Entity ...

memberEntity.Password = member.Password;

MemberRepository.Update(memberEntity);
}

這個 API 可能包含了對 Member 資料的各種邏輯驗證,而呼叫這個 API 的你可能只是為了完成密碼變更而已,這份 Code 硬要說沒什麼大問題,但也體現了從 CRUD 角度設計出的 API 可能隨著商業邏輯的日益複雜,複雜度可能會成長到非常可怕的地步,而這也是 CQRS 中想避免的事情。

Command 會這樣做

1
2
3
4
5
6
7
8
9
10
11
public void Handle(ResetPasswordCommand command)
{
var member = MemberRepository.Get(command.Id);
if(memberEntity == null)
throw new NullReferenceException("Member not found.");

//領域模型知道 ResetPassowrd 的相關驗證與知識
member.ResetPassword(command.Password);

MemberRepository.Update(member);
}

1. 貼近現實世界的用語

PO 會對我們說需要會員註冊的功能,而不會說需要新增會員的功能,而通常更換密碼忘記密碼會是完全不一樣的流程,用 UpdatePassword 很難精準說明這裡面做了什麼,你可能需要翻程式碼看流程,才能說出這個方法內包含了哪些商業情境,而這正是 Command 想避免的。

2. 貼近商業邏輯

一個 Command 只做一件事情,而 Command 所執行的流程跟邏輯應符合真實流程與情境

3. Command 內盡量不要再串其他 Command

Command 通常會對應觸發系統 Event,當 Command 串 Command 時,後續的連鎖反應容易失控,盡量讓 Command 就是獨立完成一個商業邏輯。

也許看到這邊會覺得這好像有點 DDD 的影子,定義領域模型並封裝領域知識,外部透過 Command 觸發領域模型做事,就我目前的認識,的確 DDD 通常會採用 CQRS 的設計模式來實作,但 CQRS 卻不一定要實作 DDD,不過 DDD 我還是初學者就不獻醜了。

提升效能

系統都希望提供各種角度的搜尋來滿足分析與功能需求,而這會加重資料庫的運算資源短缺問題,通常下一步就會開始將資料庫做讀寫分離的拆解,但最終逃離不了 Table Schema 需要同一套的束縛,而這個束縛在運算或是資料到達某個量級時就很難再往上提升了。

搜尋、寫入最佳化是個兩難的問題,搜尋要快通常要對 Index 設計下一些功夫,而偏偏 Index 越多寫入越慢,又或是 Table Schema 適度的反正規化對搜尋較為友善,但偏偏這會讓寫入資料維護時變得異常麻煩;反過來看,寫入要快需要盡量只寫入該寫入或該更新的資料,而正規化反而是有利於寫入的情境。當讀寫都對同一個 DB 或是同一張 Table 時這兩邊的平衡往往會讓 RD 抓狂,而 CQRS 正是能解決的問題的很好解決方案。
/images/20210815/cqrs.png
CQRS 允許 Command 與 Query 可以是 Table 等級的隔離也可以是資料庫的實體隔離,更提供針對 Command 與 Query 各自選用最佳方案的資料庫,例如:Command 選用 RDB 而 Query 選擇 NoSQL 甚至是 Elasticsearch 這種重量級搜尋服務來滿足,兩邊資料則透過 Event Sync 的方式來同步。
通常系統寫入與查詢的量也是不成比例的,一般系統大部分都在應付各種查詢,而寫入可能只有讀取不到 10 分之 1 的量,CQRS 是有辦法單獨對 Query DB 做橫向擴充,而這在傳統系統架構上是很難做到的一點。

既然這麼好,我是否該每個系統都這樣設計

有優點就有缺點,而 CQRS 的缺點是對於開發的難度提升了不止一個檔次,等等!上面不是說為了簡化才選擇 CQRS,這邊又說開發變困難,筆者態度前後不一啦(怒噴兩萬字)

先冷靜聽我說,CQRS 是想讓你的程式碼符合商業情境,並盡量符合 Single responsibility principle,這是 Code Level 的簡化,但架構卻變得更為複雜,第一你得面對兩邊資料庫的選擇問題,就算退一步說,我系統量不大,選擇同一座資料庫切 Table 可以吧,但 Command 與 Query 就是會有同步時間差,資料只能做到最終一致性,而你準備好面對這樣的改變了嗎?你應該不會想要採用 CQRS ,但抄寫兩張 Table 卻用同步的作法,這樣用單張 Table 採用 CRUD 的方式還更快一些。

當你的開始對 Command / Query 各自選用最佳的資料庫解決方案時,Event Sync 抄寫資料的實作想好解決方案了嗎?中間是透過 Queue 實作 Pub / Sub 同步嗎? 資料同步延遲的 SLO 定好了嗎? Consumer 同步速度跟不上寫入發過來的 Event 時,橫向擴充的機制是否想好了?

當開始要面對最終一致性時,不單單只是系統層面的調整,有時甚至從需求到 RD 觀念都是需要做些改變,現實世界最終一致性例子比比皆是,但在過去的大單體時代強一致性才是顯學,如今系統需要乘載的量體已經不可同日而語,除非有革命性的物理突破,不然我們都要開始習慣這個改變

講這麼多,那到底怎樣才需要用到 CQRS 實作系統? 我的建議是如果這個系統流量資料量預期成長不大,或需求不太會隨著商業情境一直改變的,例如後台系統,這種真的套個 Template 用 CRUD 簡單搞定就好,引入 CQRS 只會增加複雜度且不會有多大效益的,反之如果這個系統預期未來會持續成長,商業需求也會一直調整,那我會建議可以考慮引入 CQRS,但在前期可以選擇單一資料庫切 Table 的方式就好,等到真的量開始起來時再開始 Migration 都還來得及。

最近接到一個任務,需要協助團隊重現幾個 DB 連線時的錯誤,例如: connection pool 超過上限爆掉、connection timeout 等等,而其中一個錯誤 Pre-Login handshack 最難重現

1
2
System.Data.SqlClient.SqlException (0x80131904): Connection Timeout Expired.  The timeout period elapsed while attempting to consume the pre-login handshake acknowledgement.  This could be because the 
pre-login handshake failed or the server was unable to respond back in time. The duration spent while attempting to connect to this server was - [Pre-Login] initialization=2846; handshake=6765;

這個錯誤推測是在與 SQL Server 連線時 three-way handshake 沒有收到回應導致的失敗,可能屬於網路不穩掉封包問題導致,但問題怎麼證明?

Wireshark

首先得先確認與 SQL Server 連線時到底傳了哪些封包出去,可以透過 wireshark 側錄封包的功能來達成

1. 設定 Capture

1
dst host xxx.xxx.xxx.xxx && port 1433

/images/20210429/1.png

2. 嘗試對 SQL 做一次連線並觀察封包

/images/20210429/2.png
可以發現 Pre-login handshake 應該會有三次封包傳輸

Packet Loss

接著得想辦法重現封包丟失的狀況下,是否會引發相同的 Exception,因為不知道怎麼精準的特定封包攔下來 (如果有人會的話也歡迎留言教學一下),所以這邊透過 Clumsy 這個套件來輔助達成

1. 指定目的地的封包多少比例被攔截下來

1
outbound and ip.DstAddr = xxx.xxx.xxx.xxx

/images/20210429/3.png

2. 增加連線 handshake 的機率

因為無法精準攔截封包,所以採取短時間快速重複連線來增加封包被攔截的碰撞機率,所以我將 Connection Pool 關閉,並且開多執行序只做最簡單的連線與關閉連線,果然很快就碰到 handshake 的封包被攔掉的狀況

/images/20210429/4.png

而最後也證實了只要連線時網路不穩定導致掉封包等狀況時,底層是會引發上述錯誤的狀況

情境

最近踩到 dotnet core 3 contaienr + ms sql 2016 的問題,問題的現象是,程式只要跑到建 sql connection 那行就會 hang 在那邊,沒有 timeout 也沒有 Exception … 追查之下後發現,原來 MS SQL 2016 如果沒有調整更新,預設 TLS 好像只支援 1.0、1.1 (待確認),而我用的 base image : mcr.microsoft.com/dotnet/core/runtime:3.1 的 TLS 設定要求最低版本是 1.2,這也就引發了上述的狀況

1
2
3
4
# cat /etc/ssl/openssl.cnf 
[system_default_sect]
MinProtocol = TLSv1.2
CipherString = DEFAULT@SECLEVEL=2

解法

一般來說,基於安全性會建議至少停用 TLS 1.0 並支援 1.2 才是比較好的做法,但如果你跟我一樣只是在測試,那可以採用以下比較 workaround 的做法,在 dockerfile 加上以下幾行來調整 container tls 最低支援版本

1
2
3
4
RUN sed -i 's/DEFAULT@SECLEVEL=2/DEFAULT@SECLEVEL=1/g' /etc/ssl/openssl.cnf
RUN sed -i 's/MinProtocol = TLSv1.2/MinProtocol = TLSv1/g' /etc/ssl/openssl.cnf
RUN sed -i 's/DEFAULT@SECLEVEL=2/DEFAULT@SECLEVEL=1/g' /usr/lib/ssl/openssl.cnf
RUN sed -i 's/MinProtocol = TLSv1.2/MinProtocol = TLSv1/g' /usr/lib/ssl/openssl.cnf

這樣重新啟動也就會正常了

說來真的好久沒有寫文章了,除了工作很忙之外,目前碰到的問題也通常不是一兩篇文章可以交代的清,所以也就更新部落格的時間也就間隔越來越長了。

這次主要是想把最近針對 Async 的一些測試研究記錄下來,那我們就開始吧 !

# Async 是什麼?

Async 在 C# 語言中用來支援非同步處理的一種語法,而它的使用往往搭配 Await 一起使用,先來看看一段簡單的程式碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Program
{
static async Task Main(string[] args)
{
Console.WriteLine($"1. Hi I'm Async Demo");

//拿到非同步的任務
var task = DoAsync();

//繼續執行
Console.WriteLine($"2. Hello World!");

//等待非同步任務執行完成
await task;
Console.WriteLine($"4. End!");
}

public static async Task DoAsync()
{
//碰到 await 時,會將控制項回傳給呼叫端,並且等待非同步的方法執行完成
await Task.Run(()=>
{
Thread.Sleep(TimeSpan.FromSeconds(5));
Console.WriteLine($"3. Async method Done");
});
}
}

這段程式碼執行後可以看到以下結果

1
2
3
4
1. Hi I'm Async Demo
2. Hello World!
3. Async method Done
4. End!

觀察執行緒的切換

接著加上執行緒 Id 看看,讓我們更清楚執行緒之間是如何切換的

/images/20200919/1.png

執行結果

1
2
3
4
[1] 1. Hi I'm Async Demo
[1] 2. Hello World!
[4] 3. Async method Done
[4] 4. End!

執行流程

  • 執行緒_1 在 11 行印出了 [1] 1. Hi I'm Async Demo

  • 執行緒_1 在 14 行進入了 DoAsync 的方法中

  • 執行緒_1 在 27 行碰到 Task.Run 開啟了非同步執行的方法,並因為 await 跳出了這個 DoAsync()

  • 執行緒_1 在 16 行碰到印出了 [1] 2. Hello World!

  • 執行緒_1 在 19 行碰到 await task ,開始等待 task 執行完成,執行緒_1 釋放回到 Thread Pool

  • 執行緒_4 在非同步方法的第 30 行 印出了 [4] 3. Async method Done ,並通知 await task 執行完成

  • TaskScheduler(通常,這會是以執行緒集區為目標的預設工作排程器)依據最有效率的判斷,讓 執行緒_4 往下執行未完的部分

  • 執行緒_4 接手繼續將 await 之後還沒做完的工作執行完成,意即 21 行,印出 [4] 4. End!

稍微修改一下程式…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Program
{
static async Task Main(string[] args)
{
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 1. Hi I'm Async Demo");

//強制將非同步方法改成同步
DoAsync().Wait();

Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 2. Hello World!");

Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 4. End!");
}

public static async Task DoAsync()
{
//碰到 await 時,會將控制項回傳給呼叫端,並且等待非同步的方法執行完成
await Task.Run(()=>
{
Thread.Sleep(TimeSpan.FromSeconds(5));
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 3. Async method Done");
});
}
}

執行結果

1
2
3
4
[1] 1. Hi I'm Async Demo
[4] 3. Async method Done
[1] 2. Hello World!
[1] 4. End!

這邊可以看到 執行緒_1 在執行到 await Task.Run(()=> …) 時跳出,但因為我們下了 Wait(),所以強迫 執行緒_1 進行同步的等待。

執行緒_4 印完 [4] 3. Async method Done 後,通知 執行緒_1 繼續往下進行,所以看到 執行緒_1 接著將剩下的程式跑完

差別在哪?

當呼叫非同步執行方法時,如果一路都是用 await 並不會造成任何執行緒被封鎖,換言之該執行緒還可以在別的地方繼續服務,一旦呼叫了 ResultWait() 這類的強制同步方法,則該執行緒會被封鎖,並等待到非同步方法執行完成後才繼續完成尚未完成的後續工作,這會嚴重消耗執行緒的使用效率。

我曾經對一個 Web API 專案進行壓測,在資源給得非常有限的情境下 (約 0.5 core cpu),await 搭配 Task.Run()Result 搭配 Task.Run ,兩個 RPS 測起來差了快一倍之多,在資源極度有限下,執行緒的使用效率將大大影響整體服務的效率。

# SynchronizationContext

SynchronizationContext 是用來記錄當前執行緒環境的類別,在 ASP.NET、WPF、WinForm 都有類似的類別只是名字可能有些差異,其最主要的目的都是在非同步方法中要能調用 UI 執行緒來更新介面之類的操作,而 SynchronizationContext 就紀錄著 UI 執行緒。

Deadlock

過去寫 ASP.NET 的時候曾經踩過一次 SynchronizationContext 的雷,在 ASP.NET 中如果如果呼叫 SynchronizationContext.Currnt 會發現並不為 Null,且型別是 AspNetSynchronizationContext ,當我們用上述的 Result / Wait() 搭配 await 將會導致 Deadlock。

原因是程式碰到 await 時會先判斷 SynchronizationContext.Currnt 是否為 Null,如果是則會在 Task 結束時呼叫 TaskScheduler (通常為 Thread Pool)來安排後續工作。反之,如果 SynchronizationContext.Currnt 不為 Null 時,就會透過 SynchronizationContext.Currnt 來繼續後續的動作。

1
2
3
4
5
6
//碰到 await 時,記住了 SynchronizationContext.Currnt
await Task.Run(()=>
{
Thread.Sleep(TimeSpan.FromSeconds(5));
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 3. Async method Done");
});

而 .Result / Wait() 的方法會封鎖住 SynchronizationContext.Currnt,造成兩邊互等的情況發生,產生了 Deadlock

1
DoAsync().Wait(); //封鎖 SynchronizationContext.Currnt 的物件等待 Task 完成

而這只會發生在 SynchronizationContext.Currnt 不為 Null 的系統中,像是前述提到的 ASP.NET、WPF、WinForm,在 Console Application 並不會發生。

解決方法 ConfigureAwait(false)

如果要避免上述所提到的 Deadlock ,可以在呼叫非同步方法時加上 ConfigureAwait(false),這樣非同工作完成時就會透過另一條執行緒繼續完成後續工作

1
2
3
4
await Task.Run(()=> 
{
....
}).ConfigureAwait(false);

在 ASP.Net Core 的時代…

在 ASP.Net core 呼叫 SynchronizationContext.Current 現在只會得到 Null ,換言之剛剛發生 deadlock 的情境已經不會發生,所以過往在非同步的地方常常要用 ConfigureAwait(false) 可以不用寫了,不過如果你是寫元件或 SDK 類的,並無法預測會被使用在怎樣的環境的話,建議還是都加上會比較保險。

# Best Practice

在使用 Async / await 等非同步技巧時,最好的方式還是都盡量使用非阻斷式的寫法 await ,避免使用 Result / Wait() ,即使你已經是寫 ASP.Net core 不會發生 Deadlock 的情況,阻斷式的寫法對於執行緒的使用效率來說還是會有影響的。

為了測試方便常常會在本機起 RabbitMQ Container,但隨著系統的演進初始化 RabbitMQ 變得越來越複雜,例如:每次都要先設定 8 組 Queue,Exchange binding …等等

工程師的美德就是懶,所以開始找辦法是不是可以讓 RabbitMQ Container 起來時就自己設定好呢?

解法

RabbitMQ 有提供設定檔來初始化,分別為放在

1
2
/etc/rabbitmq/rabbitmq.config
/etc/rabbitmq/definitions.json

而這兩個檔案裡面可以設定 VirtualHost、Authorization、Exchange、Queue

rabbitmq.config
這邊特別提醒一下,內容最後面那個小點不是打錯喔,是規定就是要有的!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
{
rabbit,
[
{ loopback_users, [] }
]
},
{
rabbitmq_management,
[
{ load_definitions, "/etc/rabbitmq/definitions.json" }
]
}
].

definitions.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
{
"rabbit_version": "3.8",
"users": [
{
"name": "guest",
"password": "guest",
"tags": "administrator"
}
],
"vhosts": [
{ "name": "/" }
],
"permissions": [
{
"user": "guest",
"vhost": "/",
"configure": ".*",
"write": ".*",
"read": ".*"
}
],
"parameters": [],
"policies": [],
"exchanges": [
{
"name": "nmq",
"vhost": "/",
"type": "direct",
"durable": true,
"auto_delete": false,
"internal": false,
"arguments": {}
}
],
"queues": [
{
"name": "command",
"vhost": "/",
"durable": true,
"auto_delete": false,
"arguments": {}
}
],
"bindings": [
{
"source": "nmq",
"vhost": "/",
"destination": "command",
"destination_type": "queue",
"routing_key": "command",
"arguments": {}
}
]
}

方案一

在啟動 rabbitmq container 的時候將這兩個檔案透過 volume 的方式丟進去,這樣就可以達成目的了

1
2
3
$ docker run -it \
-v /etc/so/rabbitmq.config:/etc/rabbitmq/rabbitmq.config:ro \
-v /etc/so/definitions.json:/etc/rabbitmq/definitions.json:ro rabbitmq:3.8-management

方案二

如過你不幸 (?) 的跟我一樣是使用 windows container,volume 這個選擇不屬於你,因為 windows container 只能將整個資料夾 volume 進去,並不能指定單一檔案,所以如果將整個資料夾放進去,需要額外將一些本該在 /etc/rabbitmq 底下的檔案也都 copy 出來,才不會跑起來的時候少東少西的,但這個方法總覺得有點麻煩,所以我採用自己 build image 的方案

dockerfile

1
2
3
4
FROM rabbitmq:3.8-management

ADD rabbitmq.config /etc/rabbitmq/
ADD definitions.json /etc/rabbitmq/

透過 build image 的過程中將檔案放進去,之後起起來也都不用在下 volume 指令,也算是簡單不少