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,參數pattern與text會從 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 Token、Channel 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


2016年12月21日 星期三

PostgreSQL 建立高階分頁 API 技巧

簡單濃縮一下一些工作上建 API 經驗,以下用一個簡單的案例說明。

一個仿 Facebook 的動態文章系統假設有 3 個 Table,如下所示:

Schema

Post (文章)
pid (流水號) message (訊息) since (發布時間)
Comment (留言)
cid (流水號) message (訊息) since (發布時間)
Reply (回覆關聯)
pid (Post流水號) cid (Comment流水號)

先不考慮 Comment 底下還可以 Reply 的狀況,假設只有一層

Post List

呈現所有文章列表應該沒有疑問,直接上語法

select * from post order by since desc offset 0 limit 10;

其中,可以透過offset與limit作為 API 的參數,除了能避免一次吐出太多 Post 外,也能做到分頁的效果。

基本範例 - 使用頁數分頁:

假設每分頁呈現 25 筆 Post

第 1 頁 SQL (offset=(1-1)*25)
select * from post order by since desc offset 0 limit 25;
第 2 頁 SQL (offset=(2-1)*25)
select * from post order by since desc offset 25 limit 25;
第 3 頁 SQL (offset=(3-1)*25)
select * from post order by since desc offset 50 limit 25;

以此類推,只要照著offset = (頁數-1)*(每頁筆數)這個規則,就可以做出能夠分頁的 API 了。

如果需要知道共有幾頁,只需要將資料總數/每分頁筆數 + 1即可:

select count(*)/25+1 from post;
每分頁的筆數,可以由前端傳送,或由後端決定。若由前端傳送則要注意檢查最大筆數,以免遭到惡意攻擊。


進階範例 - 先驗分頁:

上面的作法,已經有了基本的樣子。但是如果遇到以下這種變形的 case 怎麼辦呢?

每頁 25筆 Post,給我 Post (pid=251) 那一頁的 Post,並且要可以上翻頁或下翻頁

這個案例實際的情況會用在哪裡? 常見的就是 web RESTful 的設計的需求, 這種請求通常只會給一個 Post(pid) 然後畫面必須呈現該 Post 與其上下相鄰的 Posts。該怎麼做 ?


首先,必須先預測所有 Post 所在頁數,做成 View 表,但前提是每分頁的筆數需要故定。

create or replace view v_post as
select *,row_number() over(order by since desc) as p
from post order by since desc;


其中row_number()是一個 PostgreSQL 的 Window function,顧名思義就是取得 output 的所有資料中,每個 row 自己是第幾筆資料,後面一定要接over(order by ...)來告訴它該依照什麼排序來編號。

由於row_number會從 1 開始編號,因此透過(row_number-1)/每頁數量+1的計算,就可以知道該筆資料所在的頁數了。將 View 改成這樣:

create or replace view v_post as
select *,(row_number() over(order by since desc)-1)/25+1 as p
from post order by since desc;


有了這個頁數後,透過一個 subquery 就可以得到需要的效果了

select * from v_post where p = ( select p from v_post where pid=251 )

前端人員此時就可以透過目前的頁數p來進行上翻頁或下翻頁,此時也只需要這個 View 就可以取出翻頁後的資料。


Comment List

承上的先驗分頁技巧,在"取得某個 Post 的 Comments "情況下無法預先產生 View 來計算頁數,因為pid是從前端動態傳入的。

select r.pid,c.* from reply as r,comment as c
where r.cid=c.cid where r.pid=??



範例 - function 取代 View:

但是藉由 PostgreSQL 靈活的 function 功能照樣達到這個需求。

create or replace function fn_comment(_pid int)
returns table(pid int,cid int,message varchar,since timestamp,p bigint) as $$
begin

  return query
    select r.pid,c.cid,c.message,c.since,row_number() over(order by c.since desc) as p
    from replay r,comment c
    where r.cid=c.cid and r.pid=_pid;

end;
$$ language plpgsql;


上面這個 function 就可以動態產生類似於 View 的效果,但唯一不同的是它可以等到Post(pid)傳入後,再去抓取其Comment資料。

再透過上個段落的取得頁數方法,將 function 改成這樣:

create or replace function fn_comment(_pid int_)
returns table(pid int,cid int,message varchar,since timestamp,p bigint) as $$
begin

  return query
    select r.pid,c.cid,c.message,c.since,(row_number() over(order by c.since desc)-1)/25+1 as p
    from replay r,comment c
    where r.cid=c.cid and r.pid=_pid
    order by since desc;

end;
$$ language plpgsql;


如此一來,透過呼叫fn_comment就可以取得某個 Post 下的某一分頁所有 Comments,就跟一般的 View 用法完全一樣呢!

select * from fn_comment(251) where p=2



範例 - 透過 Comment(cid) 取得該頁資料:

透過剛才的fn_comment就可以藉由Comment(cid)取得該頁的資料,且也能夠提供前端人員進一步取得前後翻頁的資料。

select * from fn_comment(251) where p=( select p from fn_comment(251) where cid=999 );



結論

上面列舉的案例,僅用3張Table與簡單的關聯來說明,目的是希望聚焦在 API 批次取資料這件事情上面,但並非唯一的作法。

透過 View 表建立預測頁數的作法其實每次的 request 都在消耗電腦的運算,較好的做法應該是在 Reply Table 就預先建立頁數的資料或其他 cache 的策略,不僅加速 response 效能外,也能減少 server 的負擔。


以上就是對資料進行分頁技巧的一些經驗分享,
提供給需要跟前端合作的後端 Developers。


2014年9月12日 星期五

網路爬蟲 - 武功密技

學習 網路爬蟲 一段時間了

也算是略懂一二
分享一下小的領略的二三式


第一式 - WebClient
  


第二式 - WebRequest


終式 - WebBrowser


最後一招










以上小小的分享一下