2017年3月23日 星期四

React 整合 ASP.NET Web API

由於在工作上屬於全端開發(前後端都要寫),以下簡單分享自己整合 React 與 ASP.NET Web API 的過程。希望幫助有這方面需求的朋友。


前後端分離

現在 Web 工程可以說日漸複雜,有許多大型系統為了使開發人員專注於改善客戶體驗與嚴謹的商業邏輯,專業的前後端分工已經是趨勢。本章不介紹兩者工作的細節或差異,重點會放在前後端整合的問題上,並使用前端最紅的函式庫 React.js 與強大的後端 ASP.NET Web API

以下介紹,會假設您已經對這兩套軟體有初步的認識。

在整合之前,我們必須要有清楚的認知「.NET 與 React 都擁有各自完整的生態系」,如果整合的工作需要徹底的改變他們彼此的開發習慣或流程,不僅成本過高更是是緣木求魚。該如何以影響最低的前提下整合,是成敗的關鍵。


Webpack

首先我以前端的角度切入,大多數開發 React 的專案都會使用 Webpack 作為網站的打包工具。

通常會搭配 webpack-dev-server 作為暫時運行的 web server。藉由這個環境我們思考一下幾個問題:

  • webpack 打包後輸出成靜態的成品的檔案路徑
  • 使用 reacte-router 必須處理 server 的 router 規則與行為
  • 測試使用 webpack-dev-server 但成品部署到 IIS 不需要改 code
由於是前後端分離的架構,後端完全不需要進行渲染 html 等工作,僅需要將資料 (JSON) 輸出即可,因此只要後端準備一個跟 webpack-dev-server 一樣的環境,就可以無痛整合。



前後端協議


為了達成這個目的,前後端必須先將規則與需求定義清楚,前後端在共同遵守這個需求即可。
以下協議為參考範例,您可以視專案狀況調整
  • 在 http://hostname/dist/... 下的檔案,如果為實體檔案,則回應實體檔案
  • webpack 輸出的資源統一放在 http://hostname/dist/ 下 (e.g. bundle.js, xxx.png...)
  • API 的請求 URL 統一以 "api" 為 prefix (e.g. http://hostname/api/book,   http://hostname/api/book/10...)
  • 其於 request 一律回應 dist 下的 index.html
    (假設您有 SPA 的需求,譬如使用 React-router 作為解決方案 e.g. http://hostname/home) 

在這個協議下,前端開發人員可以依照原先的開發環境,且不影響後端人員。等到開發完成或告一段落後,使用 webpack 輸出到後端指定的 dist 資料夾下,就可以無痛整合。(前端開發與測試時使用 webpack-dev-server 跑 web,完成後用 IIS 跑 web)

Route 規則

以下用幾個案例說明以上規範實際的樣子:

  • http://hostname/api/login (API 回應)
  • http://hostname/api/book/10 (API 回應)
  • http://hostname/dist/bundle.js (回應 js 檔案)
  • http://hostname/dist/pic.jpg (回應 jpg 檔案)
  • http://hostname/abc (回應  index.html 檔案)
  • http://hostname/abc/cde/fgh (回應  index.html 檔案)
  • http://hostname/ (回應  index.html 檔案)


修改 webpack-dev-server 設定


1. 在 http://hostname/dist/... 下的檔案,如果為實體檔案,則回應實體檔案

在您的server.js檔案中加入靜態檔案設定

var bundler = new webpackDevServer(compiler, {
 ...
 hot: true,
 stats: { colors: true }
})
// 加入下面這一行
bundler.app.use('/dist',express.static('path/to/dist'));



2. webpack 輸出的資源統一放在 http://hostname/dist/ 下 (e.g. bundle.js, xxx.png...)

在您的webpackDevServer設定中加入publicPathcontentBase兩個參數
,其中publicPath表示 Webpack 打包後,webpack-dev-server 會將資源輸出到 http://hostname/dist/... (e.g. http://hostname/dist/bundle.js)

var bundler = new webpackDevServer(compiler, {
 // 加上這兩行
 publicPath: '/dist/',
 contentBase: 'path/to/dist',
 ...
})


另外,為了日後專案完成,離開 webpack-dev-server 的環境時,依舊能保持資源路徑不變,publicPath也需要加到webpack.config.js的設定中。

module.exports = {
 entry: {
  app: ['./app/index.jsx']
 },
 output: {
  filename: 'bundle.js',
  path: 'path/to/dist',
  // 加上下面這一行
  publicPath: '/dist/'
 },
 ...
}



3. 其於 request 一律回應 dist 下的 index.html

為了日後使用 react-router 製作 SPA 的應用,這邊我們需要加入一個 route 規則,將所有的 request 導向給 dist 下的 index.html

bundler.app.use('/dist',express.static('path/to/dist'));

// 加入下面這一段規則
bundler.app.get(/^\/[-\w\/]*$/,(req,res)=>{

 res.sendFile('path/to/dist/index.html'))

})
使用 react-router 這類的套件時,所有資源請求都會從根目錄發起,以確保使用絕對路徑。e.g. <script src="/dist/bundle.js"></script> ,或會增加 <base> 標籤來明確定義。



修改 ASP.NET Web API 設定

前端修改完了,接下來也要讓後端能符合前端的運作環境。前端只需要將 webpack 打包好的成品輸出到指定的目錄下,就可以運作順暢。

注意:下面看到的網址均為 IIS Server 的環境,非 webpack-dev-server

1. 在 http://hostname/dist/... 下的檔案,如果為實體檔案,則回應實體檔案

首先我們需要在App_Start/RouteConfig.cs中,增加以下語法:

public static void RegisterRoutes(RouteCollection routes)
{
    routes.RouteExistingFiles = true;
    routes.IgnoreRoute("dist/{*file}");
    ...
}

如此一來,當 Request 為 http://hostname/dist/... 就不會進入任何 Router Controller ,整個專案的目錄與檔案會轉交給 IIS 進行解析,如果專案目錄下的 dist 目錄下也存在檔案,就會回應該檔案。
換句話說,前端人員就必須將 webpack 打包好的結果輸出到專案目錄下的 /dist 目錄。


2. API 的請求 URL 統一以 "api" 為 prefix 

我們只需要在每一個 API Controller 類別加上[RoutePrefix("api")],這麼一來,所有 API 的請求都必須以 /api/... 為開頭呼叫。就像是以下這個範例:

[RoutePrefix("api")]
public class LoginController : ApiController
{
    [HttpPost]
    [Route("login")]
    public HttpResponseMessage login()
    {
        ...
    }
}



3. 其於 request 一律回應 dist 下的 index.html

首先我們必須將原先在App_Start\RouteConfig.cs中的routes.MapRoute改掉,就像是這樣:


public static void RegisterRoutes(RouteCollection routes)
{
    routes.RouteExistingFiles = true;
    routes.IgnoreRoute("Dist/{*file}");
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
    // 把 route 規則改成這樣
    routes.MapRoute(
        name: "Default",
        url: "{*url}",
        defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
    );   
}

其中,url被設置為匹配任何規則的{*url},也就是說除了可以被匹配到的 http://hostname/api/... 之外的 request 都會被導引到HomeController,此時我們必須加入一個純 ASP.NET MVC 的 Controller 來處理,而非使用 API 的 ApiController。
HomeController.cs就像是這樣:

public class HomeController : Controller
{
    private const string INDEX_PATH = @"~\dist\index.html";

    public ActionResult Index()
    {
        string path = Server.MapPath(INDEX_PATH);
        if (System.IO.File.Exists(path))
            return Content(System.IO.File.ReadAllText(path));            
        else
            return new HttpNotFoundResult();          
    }
}

其中,我們會去讀取 dist\index.html 檔案,作為輸出。如果該檔案不存在,就回傳錯誤碼 404 Not Found。



結論

在這個架構下,原先撰寫前端程式的開發人員,可以繼續使用他熟悉的工作流程繼續開發。後端人員也不需要修改任何邏輯或開發流程,API 也可以照舊繼續開發,且互相不會影響。

在實際運作的狀況下,後端人員不需要一定把 API 撰寫好前端才可以工作,雙方僅需要將 API 的服務、回應的格式、錯誤狀態碼等規範定義清楚,雙方就可以同步進行。待 API 開發完畢後,前端再將 API 串回實際的後端即可。且可以部屬在同一台 Server 下,而不是 Node.js 與 IIS 兩個 Web Server。

前端人員串回後端 OK 後,就可以使用 webpack 將結果輸出到後端指定的 dist 目錄下,改以 IIS 作為正式服務。



其他問題

在開發或測試時,API與前端還是分開運作的,呼叫後端API屬於跨域存取。後端 API 必須在標頭 (Header) 加上 Control-Allow-Origin 才可以被測試呼叫。一般撰寫 React 的開發人員會使用 Fetch API 或是 isomorphic-fetch 作為 AJAX 呼叫。後端必須配合 Fetch API 的規範,明確定義Control-Allow-Origin為何(不可為*),且Access-Control-Allow-Credentials必須設定為true
,下面提供一個一勞永逸的作法,您可以在Web.config檔案中加上 httpProtocol 的設定。(僅在測試時使用)

<system.webServer>
    ...
    <httpProtocol>
      <customHeaders>
        <add name="Access-Control-Allow-Origin" value="http://your_front_end_test_server" />
        <add name="Access-Control-Allow-Methods" value="GET,POST,PUT,DELETE,OPTIONS" />
        <add name="Access-Control-Allow-Credentials" value="true"/>
      </customHeaders>
    </httpProtocol>
  </system.webServer>

如此一來,所有 API 回應結果時,都會在 Header 被加上以上三個設定。


Reference




2017年2月2日 星期四

SQL Server 做文本處理 TP (Text Processing)

由於工作上接觸到蠻多資料爬梳的相關工作,以下簡單介紹一些關於在 SQL Server 上做 Text Processing 的實際狀況。

去除特殊符號與中、英數分離

為了的到更乾淨純粹的資料,常常需要從雜亂的資料中取出重要的部分。如果文章都是以中文為主的話,就可以考慮斷開中文、英數與其他字符一共 3 個種類。

在 SQL Server 中很難到這一點,因為它不支援 Regular Expression(RegExp) 這類的功能。因此我這邊利用中英數 Unicode 範圍的策略來實現。


判斷是否為英數

在 SQL Server 中提供了一些「簡單的」Pattern Matching 的機制,提供我們做一些簡單的比對功能,以下是一個判斷是否為英數的片段:

if 'A' like '%[a-Z0-9]%' 
  print 'YES'
else
  print 'NO'

雖然 Pattern Matching 很接近 RegExp,但僅能比對官方文件中所述的幾種 Pattern,並無法像真正的 RegExp 一樣靈活。

判斷是否為中文

由於無法使用 RegExp,這邊使用 Unicode 來判斷

select unicode('嗨')
-- output 21992

根據 Unicode 官方描述,漢字集中收錄在 CJK Unified Ideographs (CJK統一漢字) 中,其中收錄了中日韓三種主要常見的文字,細節可以參閱官方文件。
其中,所有的漢字範圍應在 4E00 - 9FD5 之間,在這個範圍中的文字就規成一類。

-- 4E00(hex) = 19968(decimal)
-- 9FD5(hex) = 40917(decimal)
declare @i int = 19968 
while @i<=40917
begin
  print cast(@i as nvarchar(6))+' '+nchar(@i) 
  set @i+=1
end

output
19968 一
19969 丁
19970 丂
19971 七
19972 丄
19973 丅
19974 丆
19975 万
19976 丈
19977 三
19978 上
19979 下
...

上面使用 NCHAR 來將 Unicode 轉為顯示的字元,實際使用上不需要。
不要枚舉黑名單,譬如 Delimiter,因為一定會有漏掉,正確做法應該是用白名單機制,這樣取出的資料才有源頭的規則可循。

分離

透過上述兩個方法,撰寫一個簡單的迴圈,掃過字串中每一個字元,並將其分開。
declare @str nvarchar(500)=
N'★歡慶10周年★賀!!Happy New Year~ 全部 70% OFF喔'
declare @len int = len(@str)
declare @i int = 1
declare @enPattern nvarchar(15) = '%[-a-Z0-9_'']%'
declare @tmpStr nvarchar(100)=''
declare @tmpChar nvarchar(2)
declare @prevmode int = null
declare @mode int = null

while(@i<=@len)
begin
 set @tmpChar=substring(@str,@i,1)
 set @i+=1
 -- mode:1 英數 
 if @tmpChar like @enPattern  set @mode=1
 -- mode:2 漢字 
 else if unicode(@tmpChar) between 19968 and 40917  set @mode=2
 -- mode:0 其他
 else  set @mode=0

 -- 跟上一個字一樣 mode
 if @prevmode = @mode 
  set @tmpStr+=@tmpChar
 else
 -- 跟上一個字不同 mode
 begin
  -- 暫存的是 mode 1 or 2 才是要的
  if @prevmode in(1,2) print @tmpStr  
  set @tmpStr = @tmpChar
  set @prevmode = @mode
 end  
end

-- 暫存裡面清出
if len(@tmpStr)>0
 if @prevmode in(1,2) print @tmpStr

output
「★歡慶10周年★賀!!Happy New Year~ 全部 70% OFF喔」 會被處理成以下這樣:
歡慶
10
周年
賀
Happy
New
Year
全部
70
OFF
喔
在上面這個範例中,你可以自行增加 mode 來處理更多種類的文字


Table-Valued User-Defined Functions

透過 Table-Valued User-Defined Functions 我們可以將上面的功能包裝成 Segmentation Function 以便我們後續輕鬆呼叫。
create function segmentation(@text nvarchar(4000))
returns @segments table
(
  segment nvarchar(4000) NULL
)
as
begin
  declare @len int = len(@text)
  declare @i int = 1
  declare @enPattern nvarchar(15) = '%[-a-Z0-9_'']%'
  declare @tmpStr nvarchar(4000)=''
  declare @tmpChar nvarchar(2)
  declare @prevmode int = null
  declare @mode int = null

  while(@i<=@len)
    begin
      set @tmpChar=substring(@text,@i,1)
      set @i+=1
      -- mode:1 英數 
      if @tmpChar like @enPattern  set @mode=1
      -- mode:2 漢字 
      else if unicode(@tmpChar) between 19968 and 40917  set @mode=2
      -- mode:0 其他
      else  set @mode=0

      -- 跟上一個字一樣 mode
      if @prevmode = @mode 
        set @tmpStr+=@tmpChar
      else
      -- 跟上一個字不同 mode
      begin
        -- 暫存的是 mode 1 or 2 才是要的
        if @prevmode in(1,2) 
          insert @segments values(@tmpStr)    
        set @tmpStr = @tmpChar
        set @prevmode = @mode
      end    
    end

    -- 暫存裡面清出
    if len(@tmpStr)>0
      if @prevmode in(1,2) 
        insert @segments values(@tmpStr)
    return;
end

Call Function
select * from segmentation(N'★歡慶10周年★賀!!Happy New Year~ 全部 70% OFF喔')



Regular Expression

SQL Server 不支持 Regular Expression 實在非常不方便,但還好它內建整合的 Common Language Runtime (CLR) 可以幫助我們做到這一點。CLR 是一個能夠執行 .NET Framework 程式碼的環境,所以我們可以藉由撰寫 Managed 程式碼(CLR程式碼),來擴充 SQL Server 不足之處。

啟用 CLR

sp_configure 'clr enabled', 1
GO
RECONFIGURE
GO


建立 CLR 專案

為了讓 SQL Server 也能呼叫 RegExp 的功能,我將借用 C# 內建的 Regex 來完成。

開啟專案
開啟 SQL Server 範本中的 SQL Server Database Project


加入 SQL CLR User Defined Function
在加入新項目 (Ctrl+Shift+A) 中,選擇加入 SQL CLR C# 範本中的 SQL CLR User Defined Function,並修改 FileName (e.g. PatternMatching.cs or RegExp.cs ... )


CLR Table-Valued Functions

在這個 Case 中,我希望做完 RegExp 的資料能以 Table 的形式回傳,這邊使用 SQL Server 中「資料表值使用者定義函數」來實現。根據官方文件說明,實作 CLR Table-Valued User Defined Function 必須實做 IEnumerable 介面,並且指定 FillRow 方法,將程式碼改成以下這樣:

public class PatternMatching
{
    [SqlFunction(FillRowMethodName = "FillRow")]
    public static IEnumerable RegExp()
    {
        return null;
    }
    public static void FillRow(Object obj, out SqlChars col1)
    {
        col1 = null;
    }
}

其中,FillRowMethodName 是 CLR Table-Valued Function 必需要指定的,目的是指定一個方法讓你處理每一筆 row 的資料。在FillRow方法中,第 1 個參數是一整個 Row 的物件,第 2 個參數開始是 column 1, column 2 ....,以此類推。你可以從 Row 物件中取出資料並 assign 給它們。透過out修飾子再回給 CLR。


實做 IEnumerable 介面

接下來就只剩下 RegExp 的功能了,由於 RegExp 並不是一個以IEnumerable為基底的物件,所以我們只好自己實作,這邊再新增一個IRegExp.cs的檔案。由於 CLR 程式不能使用 namespace,把 namespace 拿掉後實作IEnumerable,像是這樣:

using System;
using System.Collections;

public class IRegExp : IEnumerable
{
    public IEnumerator GetEnumerator()
    {
        throw new NotImplementedException();
    }
}

IEnumerable說穿了就是一個可迭代的方法,它還需要依賴一個IEnumerator作為迭代控制的物件。因此我們將 RegExp 的功能實做在 IEnumerable 中,然後將結果交給 IEnumerator 進行迭代控制。在 IEnumerable 後面加入一個自訂的 IEnumerator 像是這樣:

class RegExpEnum : IEnumerator
{
    public object Current
    {
        get
        {
            throw new NotImplementedException();
        }
    }

    public bool MoveNext()
    {
        throw new NotImplementedException();
    }

    public void Reset()
    {
        throw new NotImplementedException();
    }
}

IEnumerable 的詳細介紹可以參閱官方文件,其中有詳盡的說明。

加入 RegExp

有了 IEnumerable 與 IEnumerator 後,就可以將 RegExp 程式碼補上去,像是這樣:

// 執行 RegExp 功能
public class IRegExp : IEnumerable
{
    private List<string> segments;

    // 參數 
    // @pattern (RegExp)
    // @text 要處理的文字
    public IRegExp(string pattern, string text)
    {
        // 進行 RegExp
        Regex r = new Regex(pattern);
        Match m = r.Match(text);
        if (m.Success) segments = new List<string>();
        while (m.Success)
        {
            foreach (Group g in m.Groups)
            {
                foreach (Capture c in g.Captures)
                {
                    // 將結果儲存起來
                    segments.Add(c.Value);
                }
            }
            m = m.NextMatch();
        }
    }
    public IEnumerator GetEnumerator()
    {
        // 將結果傳入並回傳自訂的迭代控制器
        return new RegExpEnum(segments.ToArray());
    }
}

// 迭代控制器
class RegExpEnum : IEnumerator
{
    private string[] _segment;
    private int pos = -1;
    // 將資料傳入
    public RegExpEnum(string[] segment)
    {
        _segment = segment;
    }
    public string Current
    {
        get
        {
            try
            {
                return _segment[pos];
            }
            catch (IndexOutOfRangeException)
            {
                throw new InvalidOperationException();
            }
        }
    }
    object IEnumerator.Current
    {
        get
        {
            return Current;
        }
    }
    public bool MoveNext()
    {
        pos++;
        return (pos < _segment.Length);
    }

    public void Reset()
    {
        pos = -1;
    }
}


讓 CLR Table-Valued User Defined Function 使用

最後,在 Managed 程式碼中將自訂的 IEnumerable 物件回傳,並實作 FillRow 方法就大功告成了:
public class PatternMatching
{
    [SqlFunction(FillRowMethodName = "FillRow")]
    public static IEnumerable RegExp(string pattern,string text)
    {
        return (IEnumerable)new IRegExp(pattern, text);
    }

    public static void FillRow(object row,out SqlChars segment) {
        segment = new SqlChars(row.ToString());
    }
}

其中,因為在 RegExp 方法中加上SqlFunction的屬性,它會被視為一個外部的 CLR Function,參數patterntext會從 SQL Server 傳來,分別代表RegRxp規則與要處理的文字。另外,FillRow 方法中,由於在 IEnumerator 中已經指定IEnumerator.Current回傳目前pos的字串,因此可以將該物件直接 assign 給 column1。


從 SQL Server 中加入組件

接下來,將專案建置後,就會產生一個編譯好的.dll檔,SQL Server 將透過這個檔案來實現 RegExp 功能。首先,在 SQL Server 中加入這個 dll 組件。

Create ASSEMBLY RegExp 
FROM 'D:\...\obj\Debug\PatternMatching.dll' 
WITH PERMISSION_SET = EXTERNAL_ACCESS

這裡的 Create ASSEMBLY [組件名稱] 你可以自己決定!

建立 Table-Valued User Defined Function

現在,在 SQL Server 中已經存在RegExp組件,


透過建立 Table-Valued Function 來呼叫它,像是以下這樣:

CREATE FUNCTION RegExp 
( 
 @pattern nvarchar(200),
 @text nvarchar(4000)
)
RETURNS TABLE ( pattern nvarchar(4000) )
AS
EXTERNAL NAME RegExp.PatternMatching.RegExp;
GO
呼叫外部組件的語法規則:EXTERNAL NAME [組件名稱].[Class名稱].[方法名稱]  如果你的名稱跟我的不同,可以自行修改!

從 SQL Server 中呼叫 RegExp

現在我們可以從 SQL Server 中呼叫 RegExp 了:

select * from dbo.RegExp('(\w)3','A5B3C3D2E6F7G3H2I3')



結論

在上面的介紹中簡單介紹了使用 Unicode 來處理中文,並做了漢字與英數分開的功能。下面簡單介紹了一個整合 SQL CLR 的實際案例,透過 C# 來補足 SQL Server 不足之處。也可以將它們整合就能做出更符合你需求的 Pattern Extraction 或是 Segmentation 的功能。

在 SQL Server 中運算這些資料,並不是唯一的做法,依該視情況而定。

2017年1月13日 星期五

一步一步用 .NET Web API 撰寫 LINE Webhook (LINEBot)

由於在工作上會接觸到 LINE 或 Facebook 等 Messaging API,我將過程濃縮成最精華的部分,整理在下面的介紹中,希望對需要方向的開發者有幫助。廢話不多說,直接進入主題。


建立 LINE 商用帳號

這篇文章主要的重點會擺在使用 .NET Web API 建立 LINE 的 webhook 功能,建立帳號的部分這邊僅會簡單帶過。首先先確認你已經在 LINE Business Center 中已經將機器人帳號建立完成,請注意 Messaging API 要已經是 PUBLISHED 才算是 OK。



點選 LINE Developer 可以進入主控台。可以設定 Webhook URL 或取得 Channel Access TokenChannel Secret 等必要資訊。




建立 .NET Web API Project

.NET Web API 是微軟提供的一套建立 HTTP 服務的 Framework,它可以幫助我們快速建立擁有 RESTful 風格的 Web App。




選擇 ASP.NET Web Application 專案,再選擇使用 Web API Template,就可以建立完成。


建立 LINE Webhook Controller

為了讓 LINE 將使用者訊息傳給我們處理,根據 LINE API Reference 中 Webhook API 的說明,首先必須建立一個 HTTP POST 的 API 供 LINE 呼叫。在 Controllers 目錄中加入一個 Web API 2 Controller - Empty 命名為「LineController」。



接下來,加入 Webhook 入口並設定基本的 Router。

[RoutePrefix("line")]
public class LineController : ApiController
{
    [HttpPost]
    [Route]
    public IHttpActionResult webhook()
    {   
        return Ok("OK");
    }
}

其中[RoutePrefix("line")]表示 URL 如果為 http://{hostname}/line/... 就會進入這個 Controller。
webhook()方法所套用的 Attribute:
  • [HttpPost]:規定只接受 HTTP POST 方法
  • [Route]:因為沒有給任何參數,表示 URL 為 http://{hostname}/line 時,就會執行這個方法
  • [Route("haha")]:以此類推,如果設定成這樣,就會在 URL 為 http://{hostname}/line/haha 才會執行。

完成後,就可以執行並使用 HTTP POST http://{hostname}/line 看到一個顯示 OK 的基本畫面。


綁定 Webhook URL

剛才建立好的 https://{hostname}/line 就可以設定到您帳戶主控台中的 Webhook URL,並點選「VERIFY」進行驗證,如果 URL 是有效的就會顯示 Success.


Webhook URL 是要告訴 LINE 如何將使用者的訊息傳送給你,而且必須要 SSL 加密 (https),SSL 必須是擁有第三方認證的,不能是 self signed certificate。這一點是我覺得比較麻煩的,在這邊假設您已經都準備好了。

建立 LINE Webhook Data Model

為了取得存放在 Request 中的 Body 資料,這邊採用一個比較嚴謹的作法,建立 LINE 的 Webhook data Model。這樣做主要原因有二,一是維護方便,資料格式與屬性已經都定義在 Model 中,不用每次都回到官網查文件,另外也可以借用 .NET Web API 強大的 mapping 機制,它會自動將資料對應到所指定的 Model 中,再交給我們存取。

根據官方文件的說明,從 LINE 傳遞過來的資料大概會長這樣:

{
  "events": [
    {
      "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
      "type": "message",
      "timestamp": 1462629479859,
      "source": {
        "type": "user",
        "userId": "U206d25c2ea6bd87c17655609a1c37cb8"
      },
      "message": {
        "id": "325708",
        "type": "text",
        "text": "Hello, world"
      }
    },
    {
      "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
      "type": "follow",
      "timestamp": 1462629479859,
      "source": {
        "type": "user",
        "userId": "U206d25c2ea6bd87c17655609a1c37cb8"
      }
    }
  ]
}


觀察官方文件之後,大約可以訂出一個初步的 LINE Webhook data Model。首先,建立一個全新的 Model,命名為「LineWebhookModels.cs」


Event Object
根據官方說明,傳進來的資料由數個 event 組成,所以資料表示成 {events:[ ... ]} ,其中又分為 message, follow, unfollow, join, leave, postback, beacon 7種 Event。
namespace LINE_Webhook.Models
{
    public enum EventType { message, follow, unfollow, join, leave, postback, beacon }
    
    public class LineWebhookModels
    {
        public List<Event> events { get; set; }
    }

    public class Event
    {
        public EventType type { get; set; }
        public string timestamp { get; set; }
    }
}

其中 LineWebhookModels Model 唯一整包 LINE 傳入的資料,我們使用List<Event>來表示陣列型態的 events,且使用enum來表示不同種的Type,以利後續比對或判斷。


Source Object
在每個 event 中一定會包含一個 Source Object,來表示該 event 是由誰發出,其中又分成 user, group, room 3種 Source 來源,將 Event Model 改成這樣。
public enum SourceType { user, group, room }

public class Event
{
    public EventType type { get; set; }
    public string timestamp { get; set; }
    public Source source { get; set; }
}

public class Source
{
    public SourceType type { get; set; }
    public string userId { get; set; }
    public string groupId { get; set; }
    public string roomId { get; set; }
}


Message Object
接下來就進入重頭戲,取得訊息內容。當 event 為 message type 時,就會多出 message 與 replyToken 兩個成員,其中 message 又分成 text, image, video, audio, location, sticker 6個種類。最後將 Event Model 改成這樣。
public enum MessageType { text, image, video, audio, location, sticker }

public class Event
{
    public EventType type { get; set; }
    public string timestamp { get; set; }
    public Source source { get; set; }
    public string replyToken { get; set; }
    public Message message { get; set; }
}

public class Message
{
    public string id { get; set; }
    public MessageType type { get; set; }
    public string text { get; set; }
    public string title { get; set; }
    public string address { get; set; }
    public decimal latitude { get; set; }
    public decimal longitude { get; set; }
    public string packageId { get; set; }
    public string stickerId { get; set; }
}

大功告成!這樣一來,無論是哪一種類型的訊息,透過LineWebhookModels都有辦法 mapping 進去。

建立 Model 並非唯一的方式,您也可以使用 dynamic 的型態來接收從 LINE 傳遞過來的資料。


將用戶的訊息印出來

有了LineWebhookModels後,就可以取出用戶傳過來的訊息。回到 LineController 將 webhook() 改成這樣:

public IHttpActionResult webhook([FromBody] LineWebhookModels data)
{
    if (data == null) return BadRequest();
    if (data.events == null) return BadRequest();

    foreach (Event e in data.events)
    {
        if (e.type == EventType.message) {
            string senderID  = "";
            switch (e.source.type) {
                case SourceType.user:
                    senderID = e.source.userId;
                    break;
                case SourceType.room:
                    senderID = e.source.roomId;
                    break;
                case SourceType.group:
                    senderID = e.source.groupId;
                    break;
            }
            Trace.WriteLine("傳遞者ID " + senderID);
            Trace.WriteLine("內容 " + e.message.text);
        }
    }
    return Ok();
}

除了文字訊息之外,其他擁有照片、影片或檔案等媒體類型的 message 若要取得原始資源,則要透過 LINE Get content API 額外取得,細節可以參考官方文件。


回覆用戶

能夠接收用戶訊息後,最重要的就是回覆用戶訊息了!為了介紹,這邊會以「回音」的功能進行說明 (用戶說什麼就回什麼)。首先,您必須從帳戶主控台中取得Channel Access Token。將這個資料暫時記在Web.config檔案中,以利後續取用。

<appSettings>
 <add key="AccessToken" value="Jk0lRN...."/>
</appSettings>

根據官方文件的 Reply message API 中所述,透過 HTTP POST 方法,並設定相對應 Header 與 Body 資訊後,就可以對用戶發送的訊息進行回覆。這裡採用 .NET 內建的 WebRequest 就可以輕鬆辦到:

WebRequest req = WebRequest.Create("https://api.line.me/v2/bot/message/reply");
req.Method = "POST";
req.ContentType = "application/json";
req.Headers["Authorization"] = "Bearer " + WebConfigurationManager.AppSettings["AccessToken"];

WebResponse response = req.GetResponse();
using (var streamReader = new StreamReader(response.GetResponseStream()))
{
    string result = streamReader.ReadToEnd();
    Trace.WriteLine(result);
}


先等等,還沒加上要回覆的資料,依照官方說明,送出 Reply data大概長這樣,其中 messages 最多5則,而且跟剛才介紹的 Message Object 是一樣的,可以是不同類型的回覆:

{
    "replyToken":"nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
    "messages":[
        {
            "type":"text",
            "text":"Hello, user"
        }
    ]
}


我們可以透過 Model 的機制來規範這個 Reply data,而且剛才規範的Message Model 也可以重複使用,但Message model 中的 message 成員 Type 必須為 string,不符合需求,因此需要用一個小技巧來解決。


分為 ReceiveMessage 與 SendMessage

透過繼承的方式,抽離 Message 相同的部分,留下 type 提供繼承時才實作類別,這樣就可以保持原先的enum型態,又可以擴充出string型態。

public enum MessageType { text, image, video, audio, location, sticker }
public abstract class Message<T>
{
    public string id { get; set; }
    public T type { get; set; }
    public string text { get; set; }
    public string title { get; set; }
    public string address { get; set; }
    public decimal latitude { get; set; }
    public decimal longitude { get; set; }
    public string packageId { get; set; }
    public string stickerId { get; set; }
}
public class ReceiveMessage : Message<MessageType> { }
public class SendMessage : Message<string> { }


Event Model 也改成這樣:
public class Event
{
    public EventType type { get; set; }
    public string timestamp { get; set; }
    public Source source { get; set; }
    public string replyToken { get; set; }
    public ReceiveMessage message { get; set; }
}


ReplyBody Model

將 Message 分開後,Reply data 就可以獨立成一個 Model:

public class ReplyBody
{
    public string replyToken { get; set; }
    public List<SendMessage> messages { get; set; }
}


將 Reply 獨立成一個類別

有了ReplyBody Model 後,透過 Newtonsoft.Json 就可以將資料轉換成 JOSN 的格式。此外,回覆訊息是一個可以被重複使用 (reuse) 的功能,將它獨立成一個類別有助於讓架構更好。
public class Reply
{
    public const string API_URL = "https://api.line.me/v2/bot/message/reply";
    private WebRequest req;

    public Reply(ReplyBody body)
    {
        //--- set header and body required infos ---
        req = WebRequest.Create(API_URL);
        req.Method = "POST";
        req.ContentType = "application/json";
        req.Headers["Authorization"] = "Bearer " + WebConfigurationManager.AppSettings["AccessToken"];
        
        // --- format to json and add to request body ---
        using (var streamWriter = new StreamWriter(req.GetRequestStream()))
        {
            string data = JsonConvert.SerializeObject(body);
            streamWriter.Write(data);
            streamWriter.Flush();
        }
    }

    /*
        --- send message to LINE ---
        return response data
    */
    public string send()
    {
        string result = null;
        try
        {
            WebResponse response = req.GetResponse();
            using (var streamReader = new StreamReader(response.GetResponseStream()))
            {
                result = streamReader.ReadToEnd();
            }
        }
        catch (WebException ex)
        {
            Trace.WriteLine(ex.ToString());
        }
        return result;
    }
}

這裡將Reply單獨抽離不是最佳的做法,您可以依照實際狀況將 WebRequest 等功能做更高階的抽象化,以利後續加入其他更多的 API。


實作「回音」功能

有了Reply類別後,就可以輕鬆的使用回覆訊息的功能了。回到 LineController 的 webhook 將 Reply 的功能加入,這邊目前僅處理 message type 的 event 進行回覆:
public IHttpActionResult webhook([FromBody] LineWebhookModels data)
{
    if (data == null) return BadRequest();
    if (data.events == null) return BadRequest();

    foreach (Event e in data.events)
    {
        if (e.type == EventType.message)
        {
            ReplyBody rb = new ReplyBody()
            {
                replyToken = e.replyToken,
                messages = procMessage(e.message)
            };
            Reply reply = new Reply(rb);
            reply.send();

        }
    }
    return Ok(data);
}


透過procMessage將「回音」的效果實現,在這邊僅處理 sticker 與 text 兩種 message type,其餘您可以自行擴充或改變處理的方式:
private List<SendMessage> procMessage(ReceiveMessage m)
{
    List<SendMessage> msgs = new List<SendMessage>();
    SendMessage sm = new SendMessage()
    {
        type = Enum.GetName(typeof(MessageType), m.type)
    };
    switch (m.type)
    {
        case MessageType.sticker:
            sm.packageId = m.packageId;
            sm.stickerId = m.stickerId;
            break;
        case MessageType.text:
            sm.text = m.text;
            break;
        default:
            sm.type = Enum.GetName(typeof(MessageType), MessageType.text);
            sm.text = "很抱歉,我只是一隻回音機器人,目前只能回覆基本貼圖與文字訊息喔!";
            break;
    }
    msgs.Add(sm);
    return msgs;
}

雖然 API 看似可以回覆與用戶相同的貼圖,但實際上 API 僅能回覆內建的貼圖,您可以參考這個官方釋出的 Sticker List 來確認那些是內建貼圖。 

現在大功告成,試驗一下是否能跟回音機器人講話了:



Security Issue

隨然 LINE 已經強迫要求使用 https 進行資料傳輸,但不代表 webhook server 不會被 CSRF 攻擊,因此 LINE 在傳遞給 webhook 資料時會在 Header 夾帶一個X-Line-Signature的簽名,根據官方文件說明,這個 Signature 是由 Channel Secret 作為私鑰,與 Request Body 進行 HMAC-SHA256 進行加密計算。由於 Channel Secret 只有開發人員知道,而且 Request Body 也在 SSL 加密保護下,因此只有開發人員能夠驗證 X-Line-Signature 這組簽名是否有效。



取得 Channel Secret

為了進行加密計算,從應用程式主控台中將 Channel Secret 取出並暫時寫到Web.config 中,以利後續使用:

<appSettings>
 <add key="AccessToken" value="c..."/>
 <add key="ChannelSecret" value="3d..."/>
</appSettings>


Verify the Signature

透過官方所示之演算法,進行 Signature 驗證實作。這邊借用 .NET Web API 的 Authentication and Authorization 機制來實作。保持原來的 Web API Lifecycle,複寫 AuthorizeAttribute 定義客製化驗證是最佳的方式:

public class Signature : AuthorizeAttribute
{
    public override void OnAuthorization(HttpActionContext actionContext)
    {
        HttpRequestMessage Request = actionContext.Request;

        IEnumerable<string> headerValues;
        if (Request.Headers.TryGetValues("X-Line-Signature", out headerValues))
        {
            string lineSignature = headerValues.FirstOrDefault(),
                   reqBody = Request.Content.ReadAsStringAsync().Result;

            byte[] screct = Encoding.UTF8.GetBytes(WebConfigurationManager.AppSettings["ChannelSecret"]),
                   body = Encoding.UTF8.GetBytes(reqBody),
                   hash = new HMACSHA256(screct).ComputeHash(body);       
            string mySignature = Convert.ToBase64String(hash);
            if (mySignature == lineSignature) return;
        }
        HttpResponseMessage Response = Request.CreateResponse(HttpStatusCode.ExpectationFailed);
        Response.StatusCode = HttpStatusCode.InternalServerError;
        actionContext.Response = Response;
    }
}

其中,複寫 OnAuthorization,這個方法會在進入 Controller 之前被觸發,藉由參數HttpActionContext可以從原始的 Request data 取出必要資料進行驗證。

由 Request.Headers 中取出X-Line-Signature(由LINE所發的簽名),再由 Request.Content 取出 Request body 資料,透過 .NET 內建的 HMACSHA256 物件進行加密運算。

如果加密運算後與LINE所發的簽名一致則表示該訊息為 LINE 官方所發出,否則回應 InternalServerError (500 Error)。


加上驗證屬性 AuthorizeAttribute

有了客製化的 Signature AuthorizeAttribute 後,就可以在 LineController 的 Action 中附加,表示該 Controller 必須先通過驗證,通過後才會進入該 Controller:
[HttpPost]
[Route]
[Signature]
public IHttpActionResult webhook([FromBody] LineWebhookModels data)
{
    ....
    return Ok(data);
}

做成 AuthorizeAttribute 的好處是,未來若擴充更多 LINE 相關的 API 時,直接在 Controller 中加入該 Attribute 即可。


結論

以上就是使用 .NET Web API 建立 LINE webhook 程式的介紹,希望對需要使用 C# 語言開發的朋友會有幫助。畢竟無論是官方或是網路上對於 C# 怎麼建立 LINE webhook 的詳細說明實在太少,因此在此做小小的貢獻。


GitHub
這次整個介紹的詳細的程式碼可以在我的 GitHub 上找到,有任何問題也歡迎回饋給我。謝謝。


Refreence