分類: 未分類

.NET Core WebApi 加入CORS (.net 7.0 up)與allow預檢要求

.NET Core WebApi 加入CORS (.net 7.0 up)與allow預檢要求

有時候我們希望測試Client端呼叫Server的Api,但若domain不相同或是沒有合理的開放的話,Browser會直接進行安全性的封鎖

<html>
    <head>
    </head>
    <body>
        <script language="javascript">
            window.onload = function(e){
                // Make a request for a user with a given ID
                axios.get('https://localhost:5001/api/test/history')
                .then(function (response) {
                    // handle success
                    console.log(response.status);
                })
                .catch(function (error) {
                    console.log(error.response.status);
                    // handle error
                    console.log(error);
                })
                .then(function () {
                    // always executed
                });
            }
        </script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.27.2/axios.min.js" integrity="sha512-odNmoc1XJy5x1TMVMdC7EMs3IVdItLPlCeL5vSUPN2llYKMJ2eByTTAIiiuqLg+GdNr9hF6z81p27DArRFKT7A==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    </body>
</html>

若我們真的去打了一個404不存在的網頁,但實際上Server有接到請求,所以回的了404,但是js端卻會因為CORS的Policy導致報錯,這個錯誤的例外,會導致js拿到ERR_NETWORK而且也無其他訊息或完整的異常資訊

.Net Core Startup.cs中,找到以下段落
//public void ConfigureServices(IServiceCollection services)內
services.AddCors(options => { options. AddPolicy(name: "MyAllowSpecificOrigins", policy => { policy. AllowAnyOrigin() . AllowAnyHeader(); }); });
//public void Configure(IApplicationBuilder app, IWebHostEnvironment env)內

app.UseRouting();
            
app.UseCors("MyAllowSpecificOrigins");

app.UseSentryTracing(); // 要啟用 Sentry 上的 Performance Monitoring 需加這行

app.UseAuthentication();

備註:UseCors的呼叫必須放在UseRouting之後 ,但在UseAuthorization之前,若有具名的policy,就也要具名UseCors加進來,若有具名但沒有UseCors(“xxxxxx”)的話,仍視為無效

所以成功加進去後,axios套件也是可以抓到

2024/01/30更新

大部分的CORS設定若是在Azure的app service仍可以透過CORS的Portal設定去處理掉

不過若是在本機的環境,有遇到下面的錯誤訊息(host有改過所以不是localhost)

Access to XMLHttpRequest at 'https://dev.azurewebsites.net:5001/api/Auth/app-login' from origin 'https://dev.azurewebsites.net:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

代表被preflight預檢請求擋掉了

這個時候在Api的Program.cs

var app = builder.Build(); 
//之後加上
app.UseCors(options =>
    options
        .WithOrigins("https://dev.azurewebsites.net:3000", "*") 
         //note Allow Any Origin或直接"*"不會過 
        .AllowAnyHeader()
        .AllowAnyMethod()
        .AllowCredentials());
//之前加上
app.UseHttpsRedirection();

MSDN Reference∶

https://learn.microsoft.com/zh-tw/aspnet/core/security/cors?view=aspnetcore-8.0

SQL Azure 垂直Partitioned DB Query T-SQL範例

SQL Azure 垂直Partitioned DB Query T-SQL範例

SQL Azure的連線字串雖然允許你在SQL工具上切換資料庫,但是其實要下Cross Database的T-SQL是沒有辦法允許的
因此我們需要利用Elastic database query + External Data Source來協助我們

以下假設我們有主資料庫 (A),與延伸資料庫(B)

在B的資料庫中
我們先建立以下的CREDENTIAL資訊

CREATE MASTER KEY ENCRYPTION BY PASSWORD = '{A資料庫的密碼}';
CREATE DATABASE SCOPED CREDENTIAL ds_pool WITH IDENTITY = '{A資料庫的帳號}',SECRET = '{A資料庫的密碼}';
--以下語法提供方便重新建立/測試
--drop MASTER KEY --drop DATABASE SCOPED CREDENTIAL ds_pool

ds_pool不需要用字串包起來,你可以命名成你可理解的名稱,下面我們會再用到
執行成功的話,接著執行 以下建立Remote DataSource的物件

CREATE EXTERNAL DATA SOURCE REMOTE_DS WITH ( 
   TYPE=RDBMS, 
   LOCATION='{A資料庫的Localtion}stage.database.windows.net', 
   DATABASE_NAME='{A資料庫的名稱}', 
   CREDENTIAL= ds_pool );

--以下語法提供方便重新建立/測試
--Drop EXTERNAL DATA SOURCE REMOTE_DS

這邊我們要給定一個REMOTEDS到DataSouce,並指定他的CREDENTIAL與資料庫名稱、類型與 Location,從MSDN上可以看到Location似乎可以支援檔案格式,可以填寫blob的位置,這個有機會再研究

CREATE EXTERNAL TABLE [dbo].[{A資料庫裡面的Table Or View都可以}] 
 ( [A資料庫的某一個table的欄位1] [欄位型別1], 
   [A資料庫的某一個table的欄位2] [欄位型別2], 
   .....略
 ) WITH ( DATA_SOURCE = REMOTE_DS )

--drop EXTERNAL TABLE [dbo].[{A資料庫裡面的Table Or View都可以}]
select * from [dbo].[{A資料庫裡面的Table Or View都可以}]

這邊我們必須在B資料庫端,定義 A資料庫希望 remote存取的物件的schema,可以完整也可以只撈取部分~~端看需求,在B資料庫端,也可以給定一個新的別名,語法可以參考以下。

CREATE EXTERNAL TABLE [dbo].[remote_xxxxxx]
( [A資料庫的某一個table的欄位1] [欄位型別1], 
   [A資料庫的某一個table的欄位2] [欄位型別2], 
   .....略
 ) WITH ( 
    DATA_SOURCE = REMOTE_DS
    ,SCHEMA_NAME = 'A資料庫裡面的Table Or View的schema,通常是dbo'
    ,OBJECT_NAME = '{A資料庫裡面的Table Or View}'
)

實測後,確實看起來是可以運作查詢,透過在B資料庫的連接中,直接查詢A

這與Elastic Pool似乎不一樣,而目前我們使用 到的費用也是包含在SQL Database中因此不另外計費

Currently, the elastic database query feature is included into the cost of your Azure SQL Database.

註1∶但是開一台elastic pool的主機,至少2000nt/月起跳…用途與解決問題的目的性應該不太一樣吧(沈思…)

註2∶若你的store procedure中有使用到External DataSource,語法中有使用With (nolock)的話,會出現 – “Table hints are not supported on queries that reference external tables”.的錯誤訊息

Reference

https://docs.microsoft.com/en-us/azure/azure-sql/database/elastic-query-getting-started-vertical

https://social.technet.microsoft.com/wiki/contents/articles/37170.azure-sql-database-using-external-data-sources.aspx

Line Login Jwt 驗證簽名 自行實作 c#範例

Line Login Jwt 驗證簽名 自行實作 c#範例

跟Line整合後,經過OAuth第一步驟拿到的grant_code可以再請求 Line Api拿到Open Id協定的id_token

這個是一個標準,從id_token可以解出平台授權的使用者資訊,可能包含廠商的id, 用戶的識別碼

但是若人人都可以拿這個token重新replay使用的話,那就有更大的風險

因此我們需要驗證其合法性,而這個驗證通常都要在server進行,因為我們不可能把密鑰丟到client的browser去嘛

所以還是好好的保留在Server吧。

以下Line有python3的範例∶ https://developers.line.biz/en/docs/line-login/verify-id-token/#write-original-code

今天我試著改寫一下成c#的版本,這個版本主要是驗證簽名是否合法,至於想再驗證payload上的資訊,是否到期(exp、aud)、正確性,可以再自行取出後比對。

註∶若是自行實作的jwt,可以依不用的加簽實作驗證handler,此處為line的演算法sha256為例

using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.Tokens;

var idToken =
    "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FjY2Vzcy5saW5lLm1lIiwic3ViIjoiVTVkZDllZWNjYmUzMTB..略..c3cKg";

var secret = "{請換成你的line channel secret}";

var result =TryValidateToken(idToken);

Console.WriteLine($"token is valid(only check signature): {result}");
Console.ReadKey();


string base64url_decode(string target)
{
    return Base64UrlEncoder.Decode(target);
}

bool check_signature(string key, string target, byte[] signature)
{
    var hmacsha256 = new HMACSHA256();
    hmacsha256.Key = Encoding.UTF8.GetBytes(key);
    var cal_signature = hmacsha256.ComputeHash(Encoding.UTF8.GetBytes(target));
    var signatureHashed = signature;

    return CompareArraysExhaustively(cal_signature, signatureHashed);
}

bool CompareArraysExhaustively(byte[] first, byte[] second)
{
    if (first.Length != second.Length)
    {
        return false;
    }

    bool ret = true;
    for (int i = 0; i < first.Length; i++)
    {
        ret = ret & (first[i] == second[i]);
    }

    return ret;
}


bool TryValidateToken(string token)
{
    if (string.IsNullOrWhiteSpace(token))
    {
        return false;
    }

    var handler = new JwtSecurityTokenHandler();

    try
    {
        var jwt = handler.ReadJwtToken(token); //JWT驗證物件
        if (jwt == null)
        {
            return false;
        }

        var idTokens = token.Split('.');
        var header = idTokens[0];
        var payload = idTokens[1];
        var signature = idTokens[2];

        var header_decoded = base64url_decode(header);
        var payload_decoded = base64url_decode(payload);
        var signature_decoded = Base64UrlEncoder.DecodeBytes(signature);

        var valid_signature = check_signature(secret,
            header + '.' + payload,
            signature_decoded);

        return valid_signature;
        
        //傳統驗證方法 ..
        // ClaimsPrincipal principal = null;
        // var secretBytes = Convert.FromBase64String(secret);
        //
        // var validationParameters = new TokenValidationParameters
        // {
        //     RequireExpirationTime = true,
        //     ValidateIssuer = false,
        //     ValidateAudience = false,
        //     IssuerSigningKey = new SymmetricSecurityKey(secretBytes),
        //
        //     //LifetimeValidator = LifetimeValidator
        //
        //     ClockSkew = TimeSpan.Zero
        // };
        //
        // SecurityToken securityToken;
        // principal = handler.ValidateToken(token, validationParameters, out securityToken);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"msg:{ex.Message}");
        return false;
    }
}
Line Login 的Web Js程式碼實作

Line Login 的Web Js程式碼實作

以下程式的參數請結合官方文件取用

https://developers.line.biz/en/docs/line-login/integrate-line-login/#login-flow

先在畫面置入LineLogin的按鈕

        <div class="btn btn-primary btn-sm box-shadow-primary text-white border-0 mr-3" @click.prevent="handleLineLoginButtonClick">
          Line Login
        </div>
handleLineLoginButtonClick() {
      let URL = 'https://access.line.me/oauth2/v2.1/authorize?';
      URL += 'response_type=code';
      
      //channel id
      URL += '&client_id=$your channel id$'; 

      // 與Line Developer Channel設定的一樣就好,驗證完就會經過瀏覽器轉址回來
      URL += '&redirect_uri=http://localhost:3000/';

      // 狀態碼是會從client呼叫端帶到驗證端,再從驗證端帶回
      // 因此應該可以作為一個亂數卡控確保是自   已丟出去的交易碼
      URL += '&state=abcde';   
     
      //要求存取的範圍
      URL += '&scope=openid%20profile';
      window.location.href = URL;
    },

以上足以從你的網頁導頁到line login的頁面(這個版本與2.0所提供的weblogin,還是有差別,因為我試著串接 2.0的版本,導頁過去後,沒有掃qr code登入的選項)

導頁過去line驗證通過後,會透過redirect_url轉址回來,並帶有Authorization Code
這個Authorization Code並不能直接拿來存取Profile的資料
但我們先設定某個route要能接QueryString的function如下:

beforeMount() { 
  this.checkLineLoginCallBack(); 
},
methods:{
//...略
checkLineLoginCallBack() {
      const urlParams = new URLSearchParams(window.location.search);
      if (urlParams.has('state') && urlParams.has('code')) {
        const state = urlParams.get('state');
        const code = urlParams.get('code');
        if (state === 'abcde') { 
          const URL = 'https://api.line.me/oauth2/v2.1/token?';
          const getTokenUrl = `${URL}`;
          const getTokenBody = queryString.stringify({
            grant_type: 'authorization_code',
            code,
            redirect_uri: 'http://localhost:3000/',
            client_id: '$your channel id$',
            client_secret: '$your secret$',
          });
          axios.post(getTokenUrl, getTokenBody).then((e1) => {
            const token = e1.data.access_token;
            const idToken = e1.data.id_token;
            console.log(e1);
            const getProfileApiUrl = 'https://api.line.me/v2/profile';
            axios({
              method: 'GET',
              url: getProfileApiUrl,
              responseType: 'json', // responseType 也可以寫在 header 裡面
              headers: {
                Authorization: `Bearer ${token}`, // Bearer 跟 token 中間有一個空格
              },
            }).then((e2) => {
              alert(`line user id: ${e2.data.userId}, display name:${e2.data.displayName}`);
              console.log(JSON.stringify(e2));

              const getVerifyApiUrl = 'https://api.line.me/oauth2/v2.1/verify';
              const getVerifyBody = queryString.stringify({
                client_id: '1656034758',
                id_token: idToken,
              });
              axios.post(getVerifyApiUrl, getVerifyBody).then((e3) => {
                alert(`line email: ${e3.data.email}`);
                console.log(JSON.stringify(e3));
              }).catch((error) => {
                console.log(`錯誤: ${error}`);
              });
            }).catch((error) => {
              console.log(`錯誤: ${error}`);
            });
          })
            .catch((error) => {
              console.log(error);
              alert(error);
            });
        }
      }
    }
};

上述的內容代表,Line CallBack以後,透過authorization code再一次跟line取得access Token
這個時候的access token就可以拿來放在Bearer的header去跟line的api請求資訊
注意∶authorization code與access token都有存取時間,因此timeout後就要重新請求一份
在測試的時候通常也會有錯誤訊息明確的告訴你格式錯誤、參數錯誤,建議使用postman先確認錯誤訊息或參數的問題

取得email的api
https://developers.line.biz/zh-hant/docs/line-login/integrate-line-login/#verify-id-token

Microsoft Sql Server的Table與Column的MSDescription批次維護工具

Microsoft Sql Server的Table與Column的MSDescription批次維護工具

在以前,我們專案最痛苦的事情之一,莫過於生文件。

若能透過自動化的方式將程式相關的資料庫描述都產生出來的話,那著實可以省去很多人工~

其實已經有很多自動產生描述文件的工具了,也有包含pdf、word、html等格式

偏偏這個動作最優先要做的,其實是標註欄位、表的名稱與用途

SSMS工具可以透過介面”一個一個”去補,仿間應該也有許多”付費”軟體做的到

但是沒有免費可以方便一次性維護、更新MSDescription的工具。

因此之前寫過一版,先拋磚引玉~~

提供Source Code

GitHub網址在下面

https://github.com/pin0513/MSDescriptionMakeupTool

SOLID重點複習-2.LSP Liskov替換原則

SOLID重點複習-2.LSP Liskov替換原則

最早由Barbara Liskov 由1988年提出:

子型態(subtype)必須要能夠替換它們的基底型態

 

最簡單的範例是:

就是在違反LSP的時候,常常也明顯違反OCP,就是在程式執行階段時,進行型別檢查,如以下

using System;
					
public class Program
{
	public struct Point
	{
		public double x;
		public double y;
	}
	
	public class Shape
	{
		public Shape(string shapeName)
		{
			ShapeName = shapeName;
		}
		
		public string ShapeName;
		
		public static void DrawShape(Shape t)
		{
			if (t.ShapeName == "circle")
			{
				((Circle)t).Draw();
			}
		    if (t.ShapeName == "square")
			{
				(t as Square).Draw();
			}
		}
	}
	
	
	
	public class Circle : Shape
	{
		public Point center;
		public double radius;
		
		public Circle(string typeName):base(typeName)
		{
		}
		
		public void Draw()
		{
			Console.WriteLine("this is circle");
		}
	}
	
	public class Square : Shape
	{
		public Point TopLeft;
		public double side;
		public Square(string typeName):base(typeName)
		{
		}
		
		public void Draw()
		{
			Console.WriteLine("this is square");
		}
	}
	
	
	
	public static void Main()
	{
		//註:這邊更好是用列舉的方式指定Name, 強制約定
		Shape a = new Circle("circle");
		Shape b = new Square("square");
		
		Shape.DrawShape(a);
		Shape.DrawShape(b);
	}
}

很顯然,DrawShape違反了OCP,因為每擴充一種shape的時候,這個函式就要一併作出修正

書中提及很多人肯定這種作法是很糟糕設計,但為什麼會促使程式設計師寫出這種函式呢?

主要可能是對於多型的額外開銷(overhead)認為大得難以忍受,他沒有在shape類別中定義抽象方法,以便於抽象draw行為。

然而在現今電腦發展飛速的時代中,這種多型方法呼叫的開銷都是ns等級。其實不必要對此有過度的觀點

此例中,square類別和circle類別無法替換shape類別,就是違反了LSP, 同時又迫使DrawShape違反了OCP,因此LSP的違反也潛在地違反了OCP

 

關鍵的問題:

考慮以下程式

using System;
					
public class Program
{
	public class Rectangle
	{
		protected double width;
		protected double height;
		public virtual double Height
		{
			get
			{
				return height;
			}
			
			set
			{
				height = value;
			}
		}
	
		public virtual double Width
		{
			get
			{
				return width;
			}
			
			set
			{
				width = value;
			}
		}
		
		public double Area()
		{
			return width*height;
		}
	}
	
	public class Square :Rectangle
	{
		public override double Height
		{
			get
			{
				return base.height;
			}
			
			set
			{
				base.width = value;
				base.height = value;
			}
		}
	
		public override double Width
		{
			get
			{
				return base.width;
			}
			
			set
			{
				base.height = value;
				base.width = value;
			}
		}
	}
	
	
	public static void Main()
	{
		Rectangle r = new Square();
		r.Width = 5; 
		r.Height = 4;
		//對正方向設定5再設定4的邊界,行為意圖比較像是拉動邊的長短而己
		Console.WriteLine("square area is "+r.Area());
		
		
	}
}

雖然square跟rectangle自己都可以正常運作,這種我們稱為子類別的自相容(self-consistent)及結果是正確的,可是這個結論是錯的

跑出來的結果:

square area is 16

但一個自相容的程式未必和他所有的使用端程式相容,考慮以下例子:

Rectangle r = new Square();
    var result = CalculateArea(r);

	public static double CalculateArea(Rectangle r)
	{
		r.Width = 4;
		r.Height = 5;
		
		if (r.Area() != 20)
			throw new Exception("bad area");
		
		return r.Area();
	}

其結果反而是:

Run-time exception (line 89): bad area

 

因此關鍵的問題是,撰寫這個CalculateArea的程式,並不知道其傳進來的類別,改變長的時候會導致寬也被改變掉了

因此此函式作的假設,確因為傳入了square,導致發生了錯誤。因此square與rectangle之間的關係是違反LSP的

 

書中認為或許有人會針對這個函式所存在的問題進行爭論,認為此函式的作者不能假設長寬一定是獨立的。

但問題是此函式的作者不會同意這個論點,因為此命名以rectangle為命名作為參數,確實有一些不變性與真理足以說明。

而Rectangle其中一個不變的性質就是長寬可以獨立。此函式的作者可以斷言這個不變性(檢查)。

反而是square的作者違反了不變性。

而有 趣的是他不是違反square的不變性,而是違反了rectangle的不變性

此例告訴我們

一個模型,如果獨立來看,並不具備真正意義上的有效性,有效性只能以客戶端的程式來表現

解答LSP方針

1.OOD指出Is-A關係是從”行為”來判斷

2.基於契約的設計(DBC),在單元測試時指定契約,後面有具體例子時再討論

簡單摘要至此,足以深化LSP的觀點了,先這樣吧~

 

參考書摘-無瑕的程式碼-敏捷完整篇 物件導向原則、設計模式與C#實踐

.net throw ex 與 throw 的不同之處(重新理解)

.net throw ex 與 throw 的不同之處(重新理解)

前幾天保哥在.net社群提示一個人的詢問 throw ex跟throw差在哪,他寫了類似以下範例的程式,但沒有明說差別
仔細想想,我似乎也沒特別想過這個問題
以往例外處理的議題就是不要不處理就往最外面丟,或是不要都不做事把錯誤隱藏
但往外丟本身,我習慣確實是throw,而不是throw一個變數,通常我要丟變數的時候,要不就是重新包成有意義的new exception,例如把function的意義多做描述。
否則就是不會new一個似是而非的錯誤”訊息”。
實際查了一下,stackflow網站上有人提出來:
throw ex resets the stack trace (so your errors would appear to originate from HandleException)
throw doesn’t – the original offender would be preserved.
結果我實際寫了一個winform看了一下差別,一開始我還把這兩個例子都直接寫在”同”一個function裡,結果想說,沒有不見呀…
結果原來是在同一個function裡面看不出來,因為不算是跨stack

以下是看的出來的例子..

 //for test args = new[] {"appreset"};
            try
            {
                try
                {
                    test1();
                }
                catch (Exception b)
                {
                    throw b;
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
            }


            try
            {
                try
                {
                    test2();
                }
                catch (Exception ex)
                {
                    throw;
                }
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
            }



   private static void test2()
        {
            try
            {
                throw new Exception("OK", new Exception("b"));
            }
            catch (Exception)
            {
                throw;
            }
        }

        private static void test1()
        {
            try
            {
                throw new Exception("OK", new Exception("b"));
            }
            catch (Exception a)
            {
                throw a;
            }
        }

C# 工作平行程式庫的例外處理機制

C# 工作平行程式庫的例外處理機制

最近2個月又重回C#懷抱了,昨晚追查一個底層ThreadPool Exception,然後會造成w3wp的Exception, 會造成集區重啟的異常

後來發現有一個非同步呼叫射後不理的部分,並沒有合理的包裝例外,然後又會造成.net framework底層無法Handle的例外

Legacy Code的用法是改一半的非同步呼叫方法

function method1()
{
       method2();
}

async Task<ResultObj> function method2()
{
       //略
}

後來查詢多方說法後(StackOverFlow網友建議),建議這種射後不理的整合寫法,可以採用TPL(工作型非同步執行庫)

正規TPL的非同步方法在Handle的時候如下:

其例外處理範例,因為Task的例外會拋出來的是AggregateException方法。因此可以例用以下方法來走訪所有的Exception

   public static void Main()
   {
      var task1 = Task.Run( () => { throw new CustomException("This exception is expected!"); } );

      try
      {
          task1.Wait();
      }
      catch (AggregateException ae)
      {
          foreach (var e in ae.InnerExceptions) {
              // Handle the custom exception.
              if (e is CustomException) {
                  Console.WriteLine(e.Message);
              }
              // Rethrow any other exception.
              else {
                  throw;
              }
          }
      }
   }

 

後續改寫一版後,能明確抓到射後不理的Exception,也不會造成集區崩潰就變成以下的版本了

Task listenerTask = Task.Run(() => proxy.callService<ResultObject>(inputObj)).ContinueWith( 
	t => { logException(t, proxy.ServiceName); }, TaskContinuationOptions.OnlyOnFaulted);

private static void logException(Task<BaseAddOutput> t, string serviceName)
{
	var message = string.Empty;
    if (t.Exception != null)
       message = t.Exception.Message;
    if (t.Exception.InnerException != null)
       message = t.Exception.InnerException.Message;
    Debug.Log(ConfigUtil.GetBoolean("Debug"), "system call " + serviceName + " error, msg:" + message);
}

 

當然,若不是要射後不理的話,建議還是要讓function async / wait化,來確保執行流程順序中的工作都有被完成

 

MS Docs參考

https://docs.microsoft.com/zh-tw/dotnet/standard/parallel-programming/task-based-asynchronous-programming

關於實作秒殺壓力測試的架構驗證

關於實作秒殺壓力測試的架構驗證

之前為了一個案子,實作了一個POC秒殺的情境,透過python、nginx、lucust、docker來模擬,但那時候必竟是架構新手,對於POC驗證的環節

只想的到以下的方式,以下提供參考…若有盲點或不足之處,歡迎指導…

 

一  測試內容

二  測試方法

三  測試方法

四  測試目標

五  測試環境

5-1 壓力測試部署圖

5-2 壓測用戶端與服務端主機規格

六  系統部署

6-1 系統部署架構圖

6-2相關系統容器配置表

七  性能測試結果與分析

7-1 測試步驟與結果摘要

7-2 劇情1 線上用戶30,000,開放10,000個號碼牌

7-3 劇情2線上用戶30,000,開放30,000個號碼牌

7-4 結果與發現

 

 

一  測試內容

本次測試是針對搶票系統進行壓力測試,在秒殺產品的場景中,需求遠遠大於供給,因此考慮巨大流量的情況下,我們在設計交易的接口中,加入了號碼牌機制,從入場、選位到結帳三關卡皆透過號碼牌的控制來監控與管理入場選擇產品的人流。並逐步縮小對於後端API的請求,達到集中運算資源與降低關鍵接口負載壓力的目的。這次我們主要驗證的功能包含了領取號碼牌選位結帳的功能。

 

二  測試方法

本次採用了python開源的分散式壓力測試工具Locust.io,這個工具的特性是可以快速佈署壓測用戶端的集群,並透過統一的Master發動壓力測試,並具備Dashboard,提供效能統計表與分析圖型,近期Amazon Web Service廠商已經善用其可分散式佈署的特性,透過259台用戶端,將壓力測試提升到每秒超過10萬次的後端請求的等級,請參考相關AWS官方連結

 

這次測試採用容器技術動態建立Locust測試集群,並透過Http協議發出GET請求,伺服器端主要透過Nginx+Tornado建立Load Balance與非阻塞式的Web Server,並結合容器技術動態擴充WebServer群,包含主要關鍵流程API,包含取得號碼牌請求、模擬選位請求、模擬結帳請求。關鍵流程之間,透過Redis快取技術協調並模擬用戶狀態(包含入場前、入場選位前、選位後、結帳前),最終透過MongoDB儲存持久化交易結果。

 

三  測試場景

測試場景包含了1取得號碼牌API,其主要目的是第一關控制流量,但也因為要面對第一線最多的請求流量,因此需要的資源最多。其2為模擬選位API,因應用戶取得號碼牌後的狀態變動,我們透過快取保存用戶狀態,以模擬用戶端行為。用戶在具備號碼牌的狀態下,呼叫模擬選位API,會有自動配位的邏輯處理,亂數決定客戶購買1~4張的張數,並動態查詢仍有空位的連續座位區域並隨機決定。此時會透過鎖定機制,保存在快取內,並非同步創建訂單到NoSQL的儲存體中,其狀態為未付款(Pay Status=1),並將此用戶加入到待付款的佇列中。最終為模擬結帳API,此API將會隨機取得佇列中的排隊付款用戶,並同步更新NoSQL的付款狀態(Pay Status=2),此時會標記其用戶已付款完成。

本次測試樣本將模擬銷售單一場次、6區域,依2回合進行測試,分別開放token限制為1萬、3萬,分別模擬請求流量的變化。

四  測試目標

測試獲取單一物理主機的伺服器在反應時間3秒內,其單位承載量RPS為多少,模擬併發用戶為多少。

 

採用號碼牌分流搶票流程下,在上述系統效能需求下,最終全數票券銷售完成到結帳狀態(模擬完成金流後,寫回訂單資料押付款完成的狀態)需要多久的時間。

 

五 測試環境

  5-1 壓力測試部署圖

  5-2 壓測用戶端與服務端主機規格

環境 機器型號 作業系統 硬體cpu 硬體mem
用戶端 Linux虛擬機 CentOS 7 4核 8G
服務端 Linux虛擬機 CentOS 7 4核 8G

 

六  系統部署

6-1 系統部署架構圖

6-2相關系統容器配置表

容器環境 佈署應用程式 數量# 備註
用戶端主機 Locust Master 1 統一發動測試端與測試結果Dashboard
Locust Slave 10 接受發動測試指令,執行測試腳本
服務端主機 Nginx 3 用於3個關鍵流程的Reverse Proxy分流關卡
Tornado(Token API) 5 主要面對最大流量領取號碼牌請求
Tornado(Reserve API) 3 面對已過濾擁有號碼牌的用戶進行選位模擬
Tornado(Admin API) 1 初始化資料預熱

Dashboard監控資訊

開放TokenLimit接口

MongoDb(NoSQL) 1 主要儲存最終訂單持久化與結帳後模擬金流回填狀態。
Redis Cache Server 1 主要處理關鍵流程間狀態控管

 

 

七  性能測試結果與分析

7-1 測試步驟與結果摘要

步驟一.Dashboard 初始化

步驟二.Locust配置與啟動

步驟三.抽樣觀測Server狀態與銷售狀態

步驟四 銷售完畢後,並待所有結帳佇列消耗完成後,搜集Client端數據

 

7-2 劇情1  模擬線上30,000用戶,開放10,000個號碼牌,銷售總票數為10,000張

7-2-1 Locust壓測用戶端統計資訊

開放10,000個號碼牌,銷售總票數為10,000張
模擬線上用戶數 30,000
用戶平均爬升數/秒 2,000
銷售總花費時數 1分40秒
有效訂單數(已付款,已進入數據庫) 3,971
總請求數(擷至全售完) 117,678
總平均RPS(Request per Second) 1,176.8
取號碼牌成功請求總數(HttpStatus=200) 32,268
模擬選位成功請求總數(HttpStatus=200) 10,903
模擬結帳成功請求總數(HttpStatus=200) 7,610
服務器狀態 全數存活

 

 

 

7-2-2 Locust壓測期間趨勢圖

7-2-3容器正常服務

 

7-3 劇情2  模擬線上30,000用戶,開放20,000個號碼牌,銷售總票數為10,000張

7-3-1 Locust壓測用戶端統計資訊

開放20,000個號碼牌,銷售總票數為10,000張
模擬用戶數 30,000
用戶平均爬升數/秒 2,000
銷售總花費時數 2分17秒
有效訂單數(已付款,已進入數據庫) 3,962
總請求數(擷至全售完) 156,029
總平均RPS(Request per Second) 1,138.9
取號碼牌成功請求總數(HttpStatus=200) 36,606
模擬選位成功請求總數(HttpStatus=200) 14,776
模擬結帳成功請求總數(HttpStatus=200) 6,984
服務器狀態 全數存活

 

 

7-2-2 Locust壓測期間趨勢圖

7-2-3容器正常服務

 

7-4 結果與發現

從相同的壓測用戶參數,並透過號碼牌來控制流程API的流量,我們發現號碼牌發放的控制,可以有效提升服務效率,銷售的時間更短。同時,這次銷售10,000張票在我們配置的架構下(Nginx與Tornado),單台主機可以支撐爆量請求而不會造成應用程式崩潰與異常銷售,仍然可以將其票券全數售完,並正常寫入資料庫。而本次透過容器建置所有的系統與API,說明未來管理上雲或是自建機房(IDC)可以在即短的時間內佈署。同時具備易用與易於擴充的特性。而切分微服務的概念,除了在系統佈署的配置上可以更彈性的運用,且未來更有機會隨時提升到Serverless的層次,透過雲端全託管的服務,可以有效降低維運成本。