分類: 未分類

.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

.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