作者: paul

壓力測試到底在關注什麼?

壓力測試到底在關注什麼?

最近請同事開始使用工具做壓力測試,一開始講目的是為了”知道”我們的API能否承受一定的使用者使用我們的系統,至於多少使用者呢?假定是1000個線上用戶為目標。但過了一陣子,發現大家似乎 對於要測什麼,怎麼測,測完後的行動,壓力測試跟效能測試差異,都還有一些疑慮。導致無法有效的進行。

先講”相對”最簡單的議題,怎麼測,首先應該是依賴工具,有各種技術方案,有的用JMeter,有的用Script的Tool例如K6。端看想要如何測試往下延續或保持這種壓測的節奏。

JMeter的考量可能是有GUI的壓測劇情設定編排的畫面,對於比較不寫程式的,也有機會去達成壓測的工作。一樣可以做到算是API間的相依,像是從Login拿Token,再拿Token去打 其他API做驗證後的邏輯。這些都做的到,報表也有整合好可以看到一些圖表數據。這些數據我就先不討論了,都是有定義的。

其他工具例如K6,就可以讓你用JavaScript的方式寫腳本,方便用程式的方式 自動化 的,重覆使用化的去設計你的壓測流程,這是好處,不過就會有一點點門檻。(這對程式有點基礎的就學習上相對簡易很多)

再來一層就是我們要討論 為什麼要壓力測試,壓力測試的對象從不同的面象又可以做一個粗淺的分類,例如:

  1. front-end 或是 back-end
  2. 元件或是 end 2 end 測試
  3. 單一協定架構或混合架構。

上述分類不代表什麼,只是說明你可能可以整體來看你測試對象的分類為何 ,因為能錨定你的對象跟你選擇的工具,腳本的寫法都有些關係。不過我認為,這些還是可以概念上分成由上而下拆解,還是由下而上堆疊來驗證。

由上而下,不外乎還是把你的測試對象視為一個黑盒子。可能你知道中間的構成是什麼,也可能不知道或不確定,因此透過 測試,可以幫助你推論 瓶頸在何處,但都要數據來證明。愈複雜的組成有可能有愈多的依賴。因此你一開始愈來,拆的成本就愈高。

由下而上,我認為是你可以透過很多碎片/單元化的可驗證組件逐一測試。這些分開的成本也不小。疊到符合”使用場景”要花的時間,是你需要考慮的。例如假如知道一隻極度單純的API查詢 資料庫1次的數據(先知道環境依賴的跟與業務邏輯無關的部分),其實你可以往下驗證到,例如你的登入,背後可能是1隻API結合查詢 3次 ,寫入1次 ,這樣的子的邏輯複雜度。在你設定的壓力狀況下的成績如何。再來逐步的疊加可能登入Api+查詢Api複合性的商業邏輯,不過注意必須合理,若網站所有Api不考慮相關性,相依性 平均的去壓測他們,這樣的測試也是既耗時也浪費的。

因此從上述的方向,並不是說你只能走一個方向,而是你要怎麼透過”不同的角度”去找答案!

以API的部分,舉個例子:

一開始 可能設想由上而下,我們現在電商網站最常被使用是登入嗎?可能不是,大家要嘛就已經登入了,user資訊存在cookie中 ,要嘛大家就在逛網站,而不用急著登入。那麼登入真的是最需要被驗證的接口嗎?其實可能不是。這些都端看你網站內的流程如何設計。那往對應到流程後,背後有哪些”最相關的Api分析出來後,就可能是你首要的驗證對象。

因此 使用者場景=分析=>關鍵路徑(常用不一定關鍵)=分解=>背後關鍵的API順序。這個過程去識別出測試對象,就是很重要的。

大則流程,細則程式,你也可以想象,這是一個識別風險的過程,並透過科學的方式證明他們是或不是風險,每次開發好一個API功能後,可能先單獨 驗證這隻API的效能,再接著從這次 新增的Api去往外推論影響的範圍。

最後,聊一下由下而上的概念,這邊舉個例子,就是測試參數到底怎麼決定比較好,看了k6的vus跟duration還有iteration後,我發現測試上會跟現實狀況會有脫勾的情況,例如你要測 2000 concurrent user,你的想像是一秒內同時打 2000個請求,都能被消耗,並能被快速的執行完成,是這樣嗎?往往 使用者情境不見得是這樣,就算是搶票,我們還是可以區分出讀寫比重不一定一樣的行為模式,因此結合使用者情境或是能透過你的情境去解釋,就會各自有差別,不過我想說的以本機測試為例,我們其實是希望知道受測單元的極限。例如1個直接回傳 200的api接口,在一台PC等級的筆電上,能驗證的vus同時併發量就是440vus(因為超過可能就會”環境”failed,是server被reset connection的那種),然後平均每秒 處理請求數是多少,這些都可以先針對你的技術框架有一個單元極限值,然後再往 疊加業務邏輯的方式,去收斂那個參數邊界,最後找到一個合理 測試參數,接著就是固定這個變數,往再去尋找其他可控 變數,例如組合不同邏輯下,對效能、可用性的影響為何 。

知道作法跟想做的方向後,再來看我們要怎麼解讀壓力測試API的結果來回答我們的疑問,現學現賣一下,不外乎想知道兩個面向

  1. Latency(延遲)
  2. Availability(可用性)

從Latency 延遲角度,可以結合Performance的角度就是為了知道有壓力源下的回應時間的分佈狀況。例如你可以想像在早上8:00的時候,使用系統的人最多,可能有1000人,平均等待時間會從100人的平均3秒成長到平均10秒 ,而這樣的延遲是否可以”忍受”。若不能忍受就要提高服務器的性能或是做其他的分流機制。

從Availability可用性的角度,主要是想知道API是否會中止服務,而導致關鍵流程無法完成。因此我認為就是在看 Total跟Failed的比例。假如Fail是3次 ,但是是100萬次的3次,跟10次 裡的3次 ,狀況可能完全不一樣。(但這不代表 100萬的3次 不重要)

可以想像一個情境,假如你有一個搶票的API,公告12:00開始可以進來搶100張票,結果假如你只有一台主機服務,結果因為開搶那一瞬間,湧進來10000個人來搶。瞬間把API打到當機,背後的資料庫也阻塞中止新連線,結果整個網站不要說進來了,連前面有成功進來的人也無法往下走到”金流”的流程,那麼就是典型的可用性出了很大的問題。

而這些都是可以”提前”大膽的假設,透過壓測工具來求證。那麼如何使用工具來驗證場景呢?K6官網有整理了幾種壓測的情境,來因應上述的場景
1.Smoke Test
2.Average Test
3.Stress Test
4.Spike Test
5.Soak Test

其中或許我們只要分辨出,SmokeTest目的是用小單位去驗收你的測試腳本是否正確,先行排除在重點之外。

而Average, Stress, Spike都是在給定一個壓力源(這個也是要設計的)下 ,壓力可能是量,可能是時間長(Soak),到底回應時間以及可用性如何。

上面先啦賽了這麼多,就是希望有助於結構性的先理解我們面對的東西是什麼,概念跟術語是什麼,先做好定義同步共識後,往下做的Action才有理有據,而每一次做的壓測都有如一場實驗,留下的數據,就算是沒有瓶頸的發現,那保留下來的可能那組”安全”參數、數據上的經驗都應該要能夠被延續,而不是把每個壓測都視為那麼單一的事件去操作。每個產品,每個階段,每個功能都不盡相同,但分析與解決問題的脈落其實都有一套科學思維的pattern,數據也有解讀的方式。解讀出來後,如何往下分析瓶頸(依賴)跟重構,那屬於壓測後的獨立工作,就先不在這邊一併討論了。

Server Set Cookie Cross Domain的雷

Server Set Cookie Cross Domain的雷

繼API的CORS的問題後

現在又遇到了一個set cookie的問題,下面的測試順序是反過來的,最早是遇到無法設定到Browser/Application的Cookie,後來發現可以設定的方式後,改實驗多種Domain的組合…

首先本機測試Host先改成模擬前後端分離站台的部署模式

      response.cookies.set('refreshToken', refreshToken, {
        path: '/',
        maxAge: 60 * 60 * 24 * 7,
      });

      response.cookies.set('domainTokenByMiddleWare', refreshToken, {
        path: '/',
        domain: '.azure-paul.com',
        maxAge: 60 * 60 * 24 * 7,
        secure: true,
      });

set-cookie成功!

接著改換 azure-paul.net

      response.cookies.set('refreshToken', refreshToken, {
        path: '/',
        domain: '.azure-paul.net',   
        maxAge: 60 * 60 * 24 * 7,
      });

      response.cookies.set('domainTokenByMiddleWare', refreshToken, {
        path: '/',
        domain: '.azure-paul.net',
        maxAge: 60 * 60 * 24 * 7,
        secure: true,
      });

一樣可以

透過 client端直接呼叫api server response的set-cookie也work

最後我改成這個domain

     response.cookies.set('refreshToken', refreshToken, {
        path: '/',
        domain: '.azurewebsites.net',   
        maxAge: 60 * 60 * 24 * 7,
      });

      response.cookies.set('domainTokenByMiddleWare', refreshToken, {
        path: '/',
        domain: '.azurewebsites.net',
        maxAge: 60 * 60 * 24 * 7,
        secure: true,
      });

結果無情的被拒絕set-cookie

真的沒有寫進去…驚嘆號上的hint是寫著..”current host url invalid”

這樣看來跟.net or .com沒有關係 ,倒是跟azurewebsites.net的domain被列入了卡控 名單有關係

似乎..這跟我”一開始”在/etc/hosts改成dev.azure.com 一樣,會直接被chrome卡掉

繞了一大圈後…

小結論如下
CORS跟Cookie ,若以不同domain要做到CrossDomain的存取,必須繞路避開大網站的domain
(如上azure-paul.com, azure-paul.net passed)

或是直接以NGINX Proxy建成xxxx.yyyy.com + xxxx.yyyy.com/backend,直接就可以不用遇到這種雷…

若有其他發現或知道真相的人歡迎一起分享指教~~

自行備註:

針對azurewebsites.net的set sub-domain的問題其實有人分享過 ,只是一開始哪會找這個方向XD

https://learn.microsoft.com/en-us/answers/questions/360645/cannot-set-a-subdomain-friendly-cookie

竟然是真的跟這邊的名單有關?(半信半疑)

https://publicsuffix.org/list/public_suffix_list.dat

此文的解法 真的也是綁到名單之外的Domain

透過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

實作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;
}
}

[資訊安全] 在Mac上用GPG套件將檔案加密(231108 更新)

[資訊安全] 在Mac上用GPG套件將檔案加密(231108 更新)

Gpg是一個數位簽章的檔案加密套件

其原理可以從他的祖先PGP去略知一二

利用了RSA的演算法與各種雜湊演算法對檔案進行處理,但若直接 使用RSA演算法無法對檔案大小的長度(我的照片6xx kb)內容加密,所以RSA主要是用在對檔案進行對稱加密的金鑰進行保護加密。
其原理可以再自行看一下相關連結。

下載 GPG套件

https://sourceforge.net/projects/gpgosx/

https://formulae.brew.sh/formula/gnupg

基本上,若是採用這個協定進行加密,應該會提供pgp檔案,pgp通常為對方可公開的公鑰檔案。匯入我們這端的系統後,就可以讓我們以gpg指定對方公鑰作為收件者進行檔案加密。照上圖,檔案加密的RandomKey就是由對方的公鑰做加密,以確保只有對方解的開。


然而對方應該不會提供自己的私鑰,通常是會用於加簽(後面指令中的參數為–sign),若你用自己的私鑰加簽,就要提供自己的公鑰,對方才能做驗簽,可以透過gpg –list-secret-key檢查自己的系統是否有私鑰,若沒有的話,可以上網申請一組。

ex:透過RSA對私鑰進行簡易密語設定,格式也可以指定,匯入時gpg套件都會幫你封裝好這些細節

這邊我們在mac上實際 用 gpg一樣可以匯入pgp的檔案,所以基本上應該是有向下支援了。

gpg import xxxx.asc或skr (注意,是兩個減號) => 此為私鑰檔案,通常只有一份要管理/備份好,若都沒有的話,可以透過指令/金鑰工具產生。

gpg import yyyy.pgp (注意,是兩個減號) => 此為公鑰檔案,每個訊息交換端都可以散佈公鑰檔提供對方做加密傳給自己,而只有自己的私鑰可以解開。

匯入yyyy.gpg的時候會需要輸入password

匯入後,使用以下指令就可以進行檔案加密

gpg –output “/Users/paul_huang/Desktop/{加密後的檔案}.encrypted” -u “paul(Test)” –pinentry-mode loopback –always-trust -r “收檔案者(收件者)的公鑰ID” –s2k-digest-algo “SHA1” –sign –passphrase “加簽章時使用的私鑰密語” –encrypt “/Users/paul_huang/Desktop/{欲加密的檔案}.xml”

成功加密的話,就會有以下的檔案產生  因為涉及加簽,加簽關係到驗簽,證明發送端是我進行的,因此還需要我的公鑰進行驗簽。

註:  

-u, –local-user 使用者-ID,拿指定使用者 ID 來簽署或解密 , 這一步置關重要,要帶入這個參數,對方 才知道你是用 這個人的私key加簽,對方在驗簽的時候,需要你的公鑰進行驗證簽章 ,代表保證檔案的合法性

-r 可以多組 ,代表這個加密檔案,有在收件者當中的人都可以用他的私鑰解密 

可以透過以下指令進行匯出提供接收端做驗簽

gpg –armor –export {公鑰ID} > PublicKey.pgp ( -a, –armor  會以建立以 ASCII 封裝過的輸出)

公鑰ID可以用gpg –list-keys查詢一下私鑰對應的公鑰是哪個  (因為是公鑰,所以不怕公開)

公私鑰使用上的原理如下

加密者,以RamdonKey進行對稱式加密檔案,接著以 解密端(收件者)的 “公鑰” 將Random Key進行檔案加密,對方可以用自己的私鑰解密randomkey,進行檔案解密。其中加密者會在加密後,再用私鑰進行一次簽章,此簽章只能用加密者的公鑰進行驗簽,此驗證就是確保,是加密 者進行上述加密行為,無其他人篡改過。

Reference

https://blog.miniasp.com/post/2022/05/11/gnupg

https://xeodou.me/how-pgp-works/

在Apple Mac M1 Azure Sql Edge的Docker中Restore MSSQL Bak

在Apple Mac M1 Azure Sql Edge的Docker中Restore MSSQL Bak

想從一些舊的Sql Server的Bak檔案來備份與還原DB是再自然不過的事了,但殊不知,目前試著在Parallel Desktop上的Windows 上裝 Sql Server還是有遇到Apple Silicon晶片相容的問題,所以只好寄望看看Mac下的Docker版本的Sql Edge有沒有機會可以載入Bak。

剛好找到一片影片教學(在最後的reference)

在此再記錄一下 這個簡單的步驟,先在Docker建立 mcr.microsoft.com/azure-sql-edge的容器

docker run \
  --name "/azuresqledge" \
  --runtime "runc" \
  --log-driver "json-file" \
  --restart "no" \
  --cap-add "SYS_PTRACE" \
  --publish "0.0.0.0:1433:1433/tcp" \
  --network "bridge" \
  --hostname "ce6067cab6e1" \
  --expose "1401/tcp" \
  --expose "1433/tcp" \
  --env "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
  --env "MSSQL_RPC_PORT=135" \
  --env "CONFIG_EDGE_BUILD=1" \
  --env "PAL_BOOT_WITH_MINIMAL_CONFIG=1" \
  --env "PAL_ENABLE_PAGE_ALIGNED_PE_FILE_CREATION=1" \
  --env "LD_LIBRARY_PATH=/opt/mssql/lib" \
  --env "ACCEPT_EULA=1" \
  --env "MSSQL_SA_PASSWORD=自訂密碼" \
  --label "com.azure.dev.image.build.sourceversion"="ce89b6c967e193696164e69929c3341c38ba9c7e" \
  --label "com.azure.dev.image.system.teamfoundationcollectionuri"="https://dev.azure.com/tigerdid/" \
  --label "com.microsoft.product"="Microsoft SQL Server" \
  --label "com.microsoft.version"="15.0.2000.155916" \
  --label "org.opencontainers.image.ref.name"="ubuntu" \
  --label "org.opencontainers.image.version"="18.04" \
  --label "vendor"="Microsoft" \
  --detach \
  --entrypoint "/opt/mssql/bin/permissions_check.sh" \
  "mcr.microsoft.com/azure-sql-edge" \
  "/bin/sh" "-c" "/opt/mssql/bin/launchpadd -usens=false -enableOutboundAccess=true -usesameuser=true -sqlGroup root -- -reparentOrphanedDescendants=true -useDefaultLaunchers=false & /app/asdepackage/AsdePackage & /opt/mssql/bin/sqlservr" 

測試一下使用Mac上強大的Azure Data Studio,可以連線進去

接著建立目錄並copy檔案

docker exec -it azuresqledge mkdir /var/opt/mssql/backup 
docker cp '/Users/paul_huang/Downloads/ExampleDB.bak' azuresqledge:/var/opt/mssql/backup

成功的話會像這樣
Successfully copied 202MB to azuresqledge:/var/opt/mssql/backup

Azure Data Studio跟Visual Studio Code一樣,可以找延伸模組來安裝,彈性還不錯

像 Profiler, Schema Compare,都可以再外掛進來

接著到 localhost的HOME目錄,可以找的到 “Restore”

選擇一下剛剛用Docker複製進去的檔案

剩下的匯入流程就沒啥問題,Docker的Azure Sql Edge確實也可以還原bak檔!

跨平台,讚啦,Mac無極限~

Reference Video

[踩雷] Login failed for user ”. Reason: An attempt to login using SQL authentication failed

[踩雷] Login failed for user ”. Reason: An attempt to login using SQL authentication failed

最近在Mac 上架了Docker起了一台mcr.microsoft.com/azure-sql-edge

用Azure Data Studio可以透過 Connection String登入

以下為範例1

Server=localhost;Trusted_Connection=False;Integrated Security=False;User ID=sa;Password=XXXXXXXXXX;Database=wexflow_netcore;trustservercertificate=true",

興高采烈的把這個連線字串配置到Config中,結果跑CLI過不了,得到Docker Logs資訊如下

2023-08-23 02:58:28.50 Logon       Login failed for user ”. Reason: An attempt to login using SQL authentication failed. Server is configured for Integrated authentication only. [CLIENT: 192.168.215.1]

他給的訊息是說Server is configured for Integrated authentication only.整整誤導了我1個小時

Try了老半天,最後竟然是發現那個可惡的User Id的d要給我小寫就過了!

如下

Server=localhost;Trusted_Connection=False;Integrated Security=False;User Id=sa;Password=XXXXXXXXXX;Database=wexflow_netcore;trustservercertificate=true"

總覺得這個不是很意外的錯誤,但竟然有種被 誤導的羞恥感…

謹記!

[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的快取: