作者: paul

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

[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));
[動手做]安裝 GUI Dockers管理器 – Portainer

[動手做]安裝 GUI Dockers管理器 – Portainer

既上一篇

租了3台機器,但是各自沒有使用Docker,接下來會逐漸的將所有核心都開始Docker化

Docker雖然方便,但是經過了不同機器的隔離,要去操作,仍需要ssh的話,也很麻煩

因為GUI Portainer的工具的出現,徹底的簡化了這一層

在此記錄一下相關的配置操作

首先Docker Portainer的安裝 我們安裝在Jenkins的主機上

Jenkins其實本身也可以用Docker安裝,暫時先不討論

Jenkins這台VM我們裝上去Docker

Docker CE的指令就看官網的就好了
https://docs.docker.com/engine/install/ubuntu/

接著建立Portainer的相關容器(只要幾個指令也太無腦了)

docker volume create portainer_data
docker run -d -p 8000:8000 -p 9443:9443 --name portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce:latest

接著是被管理的Docker VM上可以設定Docker Api

sudo nano /lib/systemd/system/docker.service
ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock -H=tcp://0.0.0.0:4243

systemctl daemon-reload 
sudo service docker restart
注意,重新啟動docker後,有可能會造成container不見,這邊可以再去用 jenkins重建服務

接著要去portainer設定相關環境了,portainer在初次 連結的時候可能會需要你重啟container(註)
設定了超過12碼的帳密後,就可以登入了。

接著我們將 要被遙控的docker server,透過 gui加入環境中(如下面擷圖)

重新看,可以從portainer看到

Reference

https://ithelp.ithome.com.tw/articles/10265048

https://docs.portainer.io/start/install-ce/server/docker/linux

https://docs.portainer.io/start/install-ce/server/docker/linux

https://hackmd.io/@JYU/B1w9NDGnD