【C#】Dapper UnitOfWork Repository 範例 (使用Dapper.SimpleCRUD)

目前在專案中DAL感覺很鬆散,之前有用過Entity Framework Repository Pattern,想說是不是可以用在Dapper上,找了資料後發現原來滿多Dapper擴充套件,最後決定用簡單的Unit of Work配Repository Pattern,基本上常用的方法都不用寫SQL,如果真的要寫那麼再擴充出來就好,擴充性非常棒。Unit of Work可自行選擇資料庫及字串,此篇示範是用SqlLite。

我是參考GitHub中的DapperUnitOfWork,自己在搭配Dapper.SimpleCRUD改寫,有時候覺得在寫Entity Framework : )

如果不懂Repository Pattern,不彷看下MSDN所解釋的Entity Framework Repository與No Repository,可以從圖知道對於抽換資料庫有很大幫助,雖然網路評斷很兩極,但我覺得要看場合使用才是,這裡就不多談了。

一個Repository代表一個Table,每個TableRepository在合成IGenericRepository(通用方法),把一些基本的CRUD寫在GenericRepository,再搭配Unit of Work把這些Repository做整理。想像大抽屜(Unit of Work)中裡有許多小抽屜(Repository),而每個小抽屜中會連結後面的GenericRepository(通用方法)。

專案說明

【DapperRepositoryPatternExample】 主程式範例
【Domain】 存放Entities
【UnitOfWorks】 DAL核心,存放Repository等

Nuget套件安裝

主要套件框處即可,剩下是為了支援SqlLite所安裝的。

Entities

基本上每個Entity都要繼承該抽象類別,為了識別資料用。

public abstract class EntityIdentify
{
    [Key]
    public int Id { get; set; }
}

Editable(false)是為了不Mapping,關於更多Attribute請參考Dapper.SimpleCRUD說明(後面會放參考資料)。

public class Member : EntityIdentify
{
    public string Name { get; set; }
    public string DisplayName { get; set; }

    [Editable(false)]
    public string FullName => $"{Name}({DisplayName})";
}

Repository

IGenericRepository

public interface IGenericRepository<TEntity> where TEntity : EntityIdentify
{
    TEntity Get(int id);
    TEntity Get(TEntity entity);
    IEnumerable<TEntity> GetAll();
    void Add(TEntity entity);
    void AddRange(IEnumerable<TEntity> entities);
    void Update(TEntity entity);
    void Remove(int id);
    void Remove(TEntity entity);
    void RemoveRange(IEnumerable<TEntity> entities);
}

GenericRepository (實作IGenericRepository)

public abstract class GenericRepository<TEntity> : IGenericRepository<TEntity>
    where TEntity : EntityIdentify
{
    protected IDbTransaction Transaction { get; }
    protected IDbConnection Connection => Transaction.Connection;

    public GenericRepository(IDbTransaction transaction)
    {
        Transaction = transaction;
    }

    public TEntity Get(int id)
    {
        return Connection.Get<TEntity>(id, Transaction);
    }

    public TEntity Get(TEntity entity)
    {
        if (entity == null)
            throw new ArgumentNullException("entity");

        return Get(entity.Id);
    }

    public IEnumerable<TEntity> GetAll()
    {
        return Connection.GetList<TEntity>(String.Empty, transaction: Transaction);
    }

    public void Add(TEntity entity)
    {
        if (entity == null)
            throw new ArgumentNullException(nameof(entity));

        entity.Id = default(int);
        entity.Id = Connection.Insert(entity, Transaction) ?? default(int);
    }

    public void AddRange(IEnumerable<TEntity> entities)
    {
        foreach (var entity in entities)
        {
            Add(entity);
        }
    }

    public void Update(TEntity entity)
    {
        if (entity == null)
            throw new ArgumentNullException(nameof(entity));

        Connection.Update(entity, Transaction);
    }

    public void Remove(int id)
    {
        Connection.Delete<TEntity>(id, Transaction);
    }

    public void Remove(TEntity entity)
    {
        if (entity == null)
            throw new ArgumentNullException(nameof(entity));

        Remove(entity.Id);
    }

    public void RemoveRange(IEnumerable<TEntity> entities)
    {
        foreach (var entity in entities)
        {
            Remove(entity);
        }
    }

    /// <summary>
    /// 取TEntity的TableName
    /// </summary>
    protected string GetTableNameMapper()
    {
        dynamic attributeTable = typeof(TEntity).GetCustomAttributes(false)
            .SingleOrDefault(attr => attr.GetType().Name == "TableAttribute");

        return attributeTable != null ? attributeTable.Name : typeof(TEntity).Name;
    }
}

IMemberRepository (如果是針對"該資料表用"請在這Interface定義),別忘記合成IGenericRepository,這樣就有通用方法。

public interface IMemberRepository : IGenericRepository<Member>
{
    IEnumerable<Member> FindName(string name);
    Member FindFirstOrDefaultName(string name);
}

MemberRepository (實作IMemberRepository),當邏輯太複雜可用Dapper原生方式進行。

public class MemberRepository : GenericRepository<Member>, IMemberRepository
{
    private string TableName => GetTableNameMapper();

    public MemberRepository(IDbTransaction transaction) : base(transaction)
    {
    }

    public IEnumerable<Member> FindName(string name)
    {
        return Connection.GetList<Member>(new { Name = name }, Transaction);
    }

    public Member FindFirstOrDefaultName(string name)
    {
        // 此範例是為了展示SQL,當邏輯太複雜可用Sql來撰寫
        string sql = $"Select * From {TableName} Where Name = @Name";

        return Connection.QueryFirstOrDefault<Member>(sql, new Member {Name = name}, Transaction);
    }
}

IUnitOfWork (Complete代表交易完成)

public interface IUnitOfWork : IDisposable
{
    IMemberRepository MemberRepository { get; }

    void Complete();
}

UnitOfWork

public class UnitOfWork : IUnitOfWork
{
    private IDbConnection _connection;
    private IDbTransaction _transaction;
    private IMemberRepository _memberRepository;
    private bool _disposed;

    public UnitOfWork(Dialect dialect, ConnectionSelect connectionSelect)
    {
        var factory = new ConnectionFactory(dialect, connectionSelect);

        _connection = factory.CreateDbConn();
        _connection.Open();
        _transaction = _connection.BeginTransaction();
    }

    public IMemberRepository MemberRepository => 
        _memberRepository ?? (_memberRepository = new MemberRepository(_transaction));

    public void Complete()
    {
        try
        {
            _transaction.Commit();
        }
        catch
        {
            _transaction.Rollback();
            throw;
        }
        finally
        {
            _transaction.Dispose();
            _transaction = _connection.BeginTransaction();
            ResetRepositories();
        }
    }

    private void ResetRepositories()
    {
        _memberRepository = null;
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    private void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                if (_transaction != null)
                {
                    _transaction.Dispose();
                    _transaction = null;
                }
                if (_connection != null)
                {
                    _connection.Dispose();
                    _connection = null;
                }
            }
            _disposed = true;
        }
    }

    ~UnitOfWork()
    {
        Dispose(false);
    }
}

主程式範例

    static void Main()
    {
        using (var unitOfWork = new UnitOfWork(Dialect.SqlLite, ConnectionSelect.SqlLiteLocalDb))
        {
            // 【Get】
            var getData = new Member { Id = 1 };
            var member1 = unitOfWork.MemberRepository.Get(1);
            var member2 = unitOfWork.MemberRepository.Get(getData);
            var memberAll = unitOfWork.MemberRepository.GetAll();

            // 【Add】
            // (SqlLite會有問題 不支援SCOPE_IDENTITY,請自行擴充SqlLite的Add)

            // 【Update】
            //var updateData = new Member { Id = 2, Name = "UpdateName", DisplayName = "Update" };
            //unitOfWork.MemberRepository.Update(updateData);

            // 【Remove】
            //unitOfWork.MemberRepository.Remove(3);

            // FindName
            var findnameData = unitOfWork.MemberRepository.FindName("Vincent");
            var findFirstOrDefaultName = unitOfWork.MemberRepository.FindFirstOrDefaultName("Jasmine");

            Console.WriteLine("Get範例(GenericRepository)");
            Console.WriteLine("------------------------------");
            Console.WriteLine($"member1:{member1.FullName}");
            Console.WriteLine($"member2:{member2.FullName}");
            Console.WriteLine($"member:{string.Join(", ", memberAll.Select(m => m.FullName))}");
            Console.WriteLine("------------------------------");

            Console.WriteLine();

            // FindName (MemberRepository)
            Console.WriteLine("FindName範例(MemberRepository)");
            Console.WriteLine("------------------------------");
            Console.WriteLine($"member:{string.Join(", ", findnameData.Select(m => m.FullName))}");
            Console.WriteLine($"member:{findFirstOrDefaultName.FullName}");
            Console.WriteLine("------------------------------");
            unitOfWork.Complete();
        }

        Console.ReadKey();
    }

總結

Unit of Work搭配Repository簡單易用,大家可參考看看,目前DAL層我應該都會用此方式,除非特殊需求及找到更好的替代。

參考資料

Github clone持續失敗,先放在Google雲端上,之後待補至Github。
SourceCode

Github DapperUnitOfWork
Github Dapper.SimpleCRUD
MSDN Entity Framework Repository

LineBot關於Token失效解決方式 (Short lived token取代Long lived token)

2018/10/22 更正

目前11月尚未取消Long lived token,目前Line在找尋其他取代方式,此篇可參考看看。

問題

此篇是用LineBotSDK(0.8.2)作範例,如果沒用套件也可參考解決思路。

目前Line公告提到API的Long lived token在2018/11月後取消(主要是安全性問題),取而代之將是Short lived token,而Short lived token是從API從獲取,有效時間為30天,該API需傳入[Channel ID]與[Channel secret]後會得到一個Short lived token,後面會詳細說明與實作方式。

關於Long lived token在11月後被取消公告

關於Short lived token API

解決方法

得知問題後,來說明一下我的方式。在Bot物件實體化會去API取Short lived token並存取至資料表,我給予25天存活期(雖然存活30天但得有緩衝時間),每次實體化都會檢查Token是否過期,如過期再次上述方式。此篇示範用SQLite來做資料存取。

Channel ID、Channel secret資訊在Line Developers後台

資料庫結構

一開始會建立SQLite資料表,結構如下。當然可以不用一樣,可更改自己的需求。

SQLite View

Id:識別子
ChannelId:ChannelId
Secret:ChannelSecret
AccessToken:存取Short lived token
TokenCirculationTime:Token循環天數(預設25天)
LastUpdateTime:最後更新時間
ExpirationTime:Token過期時間

程式實作

主程式 (LineId請自行填入)

class Program
{
    static void Main()
    {
        InitDb.CreatSqliteDatabase();
        string lineId = "YourLineId";

        // Extension Method
        var bot = new Bot("").InstanceShortLivedToken();
        bot.PushMessage(lineId, "Extension Method");

        // Normal Method
        var botService = new BotService().Instance();
        botService.PushMessage(lineId, "Normal Method");

        Console.WriteLine("訊息發送成功!");
        Console.Read();
    }
}

Model

public class ChannelSetting
{
    public int Id { get; set; }
    public string ChannelId { get; set; }
    public string Secret { get; set; }
    public string AccessToken { get; set; }
    public DateTime LastUpdateTime { get; set; }
    public DateTime ExpirationTime { get; set; }
    public int TokenCirculationTime { get; set; } = 25;
}

建立SQLite資料庫 (原本DataBase下的資料可先自行刪除)

public static class InitDb
{
    public static void CreatSqliteDatabase()
    {
        if (File.Exists(DbPath())) return;

        SQLiteConnection.CreateFile(DbPath());

        using (var cn = new SQLiteConnection(GetConnectionString()))
        {
            // Create Table
            string sql = @"CREATE TABLE IF NOT EXISTS LineChannelSetting 
                     (Id INTEGER PRIMARY KEY AUTOINCREMENT, 
                     ChannelId varchar(20), 
                     Secret varchar(40), 
                     AccessToken varchar(250), 
                     TokenCirculationTime NUMERIC DEFAULT 25, 
                     LastUpdateTime datetime default current_timestamp, 
                     ExpirationTime datetime default current_timestamp);";
            cn.Execute(sql);

            // Insert Channel
            sql = "Insert into LineChannelSetting (ChannelId, Secret) Values (@ChannelId, @Secret);";
            var channelSetting = new ChannelSetting()
            {
                ChannelId = Properties.Settings.Default.ChannelId,
                Secret = Properties.Settings.Default.Secret
            };

            cn.Execute(sql, channelSetting);
        }
    }

    private static string DbPath()
    {
        return $@"{AppDomain.CurrentDomain.BaseDirectory.GetParentDirectoryPath(3)}{Properties.Settings.Default.DbPath}";
    }

    private static string GetConnectionString()
    {
        return $"data source={DbPath()}";
    }
}

Channel ID與Channel secret我是放在Config檔。如果在實作中,建議Config檔放Channel ID就好,剩下都存資料表,可把Channel ID當作條件值來查找其他資訊。

整章節的主要核心,實體化會取檢查Token是否過期,如過期會去取新的Short lived token來取代。
BotService

public class BotService
{
    private readonly string _channelId;
    private string _channelSecret => GetCurrentSecret();

    /// <summary>
    /// BotService
    /// </summary>
    /// <param name="channelId">填入channelId</param>
    public BotService(string channelId = null)
    {
        _channelId = channelId ?? Properties.Settings.Default.ChannelId;
    }

    public Bot Instance()
    {
        if (IsUpdateNewToken())
        {
            UpdateNewToken();
        }

        return new Bot(GetCurrentToken());
    }

    private void UpdateNewToken()
    {
        var setting = new ChannelSetting()
        {
            ChannelId = _channelId,
            AccessToken = GetNewShortLivedToken(),
            LastUpdateTime = DateTime.Now,
            ExpirationTime = DateTime.Now.AddDays(GetTokenCirculationDay())
        };

        using (var cn = new SQLiteConnection(GetConnectionString()))
        {
            string sql = @"Update LineChannelSetting 
                           Set AccessToken=@AccessToken, 
                               LastUpdateTime=@LastUpdateTime, 
                               ExpirationTime=@ExpirationTime 
                           Where ChannelId=@ChannelId";

            cn.Execute(sql, setting);
        }
    }

    /// <summary>
    /// Token 循環天數 (最大值為30天)
    /// </summary>
    private int GetTokenCirculationDay()
    {
        int circulationDay;

        using (var cn = new SQLiteConnection(GetConnectionString()))
        {
            string sql = @"Select TokenCirculationTime From LineChannelSetting Where ChannelId=@ChannelId";

            circulationDay = cn.Query<ChannelSetting>(sql, GetChannelIdSetting()).FirstOrDefault().TokenCirculationTime;
        }

        return circulationDay > 30 ? 30 : circulationDay;
    }

    /// <summary>
    /// 取目前的ChannelSecret
    /// </summary>
    private string GetCurrentSecret()
    {
        using (var cn = new SQLiteConnection(GetConnectionString()))
        {
            string sql = @"Select Secret From LineChannelSetting Where ChannelId=@ChannelId";

            return cn.Query<ChannelSetting>(sql, GetChannelIdSetting()).FirstOrDefault()?.Secret;
        }
    }

    /// <summary>
    /// 取目前的TokenAccess
    /// </summary>
    private string GetCurrentToken()
    {
        using (var cn = new SQLiteConnection(GetConnectionString()))
        {
            string sql = @"Select AccessToken From LineChannelSetting Where ChannelId=@ChannelId";

            return cn.Query<ChannelSetting>(sql, GetChannelIdSetting()).FirstOrDefault()?.AccessToken;
        }
    }

    private bool IsUpdateNewToken()
    {
        var setting = GetSettingTime();

        if (string.IsNullOrEmpty(setting.AccessToken) ||
            DateTime.Now > setting.ExpirationTime)
        {
            return true;
        }

        return false;
    }

    private ChannelSetting GetSettingTime()
    {
        ChannelSetting setting;

        using (var cn = new SQLiteConnection(GetConnectionString()))
        {
            string sql = @"Select AccessToken, ExpirationTime 
                           From LineChannelSetting 
                           Where ChannelId=@ChannelId";

            setting = cn.Query<ChannelSetting>(sql, GetChannelIdSetting()).FirstOrDefault();
        }

        return setting;
    }

    /// <summary>
    /// 取一個新的ShortLivedToken (有效時間為30天)
    /// </summary>
    private string GetNewShortLivedToken()
    {
        return Utility.IssueChannelAccessToken(_channelId, _channelSecret).access_token;
    }

    private ChannelSetting GetChannelIdSetting()
    {
        return new ChannelSetting() { ChannelId = _channelId };
    }

    private static string DbPath()
    {
        return $@"{AppDomain.CurrentDomain.BaseDirectory.GetParentDirectoryPath(3)}{Properties.Settings.Default.DbPath}";
    }

    private static string GetConnectionString()
    {
        return $"data source={DbPath()}";
    }
}

上述BotService中裡面有個私用方法就是來取新的Short lived token。

    /// <summary>
    /// 取一個新的ShortLivedToken (有效時間為30天)
    /// </summary>
    private string GetNewShortLivedToken()
    {
        return Utility.IssueChannelAccessToken(_channelId, _channelSecret).access_token;
    }

也可用Extension Method,請自行更改命名。

public static class BotExtension
{
    public static Bot InstanceShortLivedToken(this Bot bot, string channelId = null)
    {
        return new BotService(channelId).Instance();
    }
}

實體化有兩種方式,基本上都要動到原本的程式碼。

// Extension Method
var bot = new Bot("").InstanceShortLivedToken();
            
// Normal Method
var botService = new BotService().Instance();

參考資料

SourceCode

.NET Walker 使用C#開發Linebot(25) - 動態 re-issue short-lived channel access token

【Asp.Net MVC5】Fluent Validation 簡單範例

目前開始學習ASP.NET MVC5一些開發技巧,之後會開始學習.Net Core。

這次所要示範的是Fluent Validation,算是滿完整的一套驗證,擴充性也強,接著,為什麼我會認識到這套,主要是用Code First的Fluent API並不會對欄位做驗證(註:Fluent API只有單純Mapping),而我又不想用Data Annotation方式,會導致類非常的亂,還是喜歡類乾乾淨淨,驗證邏輯就拆開吧,當然比較麻煩些囉。此次文章只會涉及Fluent Validation。

Nuget套件安裝

建立User類別

我在[Model]資料夾下建立一個User類別。

namespace FluentValidationExample.Models
{
    public class User
    {
        [Display(Name = "姓名")]
        public string Name { get; set; }

        [Display(Name = "信箱")]
        public string Email { get; set; }

        [Display(Name = "生日")]
        public DateTime BirthdayTime { get; set; }

        [Display(Name = "密碼")]
        public string Password { get; set; }

        [Display(Name = "確認密碼")]
        public string ConfirmPassword { get; set; }

        [Display(Name = "金額")]
        public int Money { get; set; }

        [Display(Name = "年齡")]
        public int Age { get; set; }
    }
}

開始建立驗證規則

在專案中建立[Validation]資料夾,在底下建立一個ValidationUser類並實作抽象類別AbstractValidator。這裡開始撰寫驗證邏輯,示範如下。

namespace FluentValidationExample.Validation
{
    public class ValidationUser : AbstractValidator<User>
    {
        public ValidationUser()
        {
            // 當規則遇到第一個錯誤就停止不繼續
            // 如果想要該欄位全部錯誤資訊請選擇CascadeMode.Continue
            CascadeMode = CascadeMode.StopOnFirstFailure;

            RuleFor(u => u.Name)
                .NotEmpty().WithMessage("{PropertyName} 必須輸入值");

            RuleFor(u => u.Email)
                .NotEmpty().WithMessage("{PropertyName} 必須輸入值")
                .EmailAddress().WithMessage("{PropertyName} 請填寫正確");

            // 如果邏輯太複雜可用Must拆出來,例如檢查身分證居留證等
            RuleFor(u => u.BirthdayTime)
                .Must(ValidateAge).WithMessage("{PropertyName} 必須滿足18");

            RuleFor(u => u.Password)
                .NotEmpty().WithMessage("{PropertyName} 必須輸入值")
                .Must(p => p?.Length >= 8).WithMessage("{PropertyName} 必須大於等於8");

            RuleFor(u => u.ConfirmPassword)
                .NotEmpty().WithMessage("{PropertyName} 必須輸入值")
                .Equal(x => x.Password).WithMessage("密碼輸入不一");

            RuleFor(u => u.Money)
                .GreaterThan(100).WithMessage("{PropertyName} 必須大於 100");

            // 當When條件成立才去檢查數值
            RuleFor(u => u.Age)
                .GreaterThan(100).WithMessage("當金額999 {PropertyName} 必須大於 100歲").When(u => u.Money == 999);
        }

        private bool ValidateAge(DateTime Age_)
        {
            DateTime Current = DateTime.Today;
            int age = Current.Year - Convert.ToDateTime(Age_).Year;

            return age >= 18;
        }
    }
}

建立ValidatorFactory

在[Validation]建立ValidatorFactory類並實作ValidatorFactoryBase,建立此工廠是為了維護姓,往後只要ValidatorFactory()方法內增加對應的驗證即可,對於Controllers會非常乾淨。

namespace FluentValidationExample.Validation
{
    public class ValidatorFactory : ValidatorFactoryBase
    {
        private static Dictionary<Type, IValidator> validators = new Dictionary<Type, IValidator>();

        static ValidatorFactory()
        {
            // 往後增加驗證在此註冊
            validators.Add(typeof(IValidator<User>), new ValidationUser());
        }

        public override IValidator CreateInstance(Type validatorType)
        {
            IValidator validator;
            if (validators.TryGetValue(validatorType, out validator))
            {
                return validator;
            }
            return validator;
        }
    }
}

Global.asax註冊

建立好工廠後,接著,要去Global.asax做註冊動作。

namespace FluentValidationExample
{
    public class MvcApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            RouteConfig.RegisterRoutes(RouteTable.Routes);

            ValidationConfiguration();
        }

        private void ValidationConfiguration()
        {
            FluentValidationModelValidatorProvider.Configure(provider =>
            {
                provider.ValidatorFactory = new ValidatorFactory();
            });
        }
    }
}

以上前置動作已完成,現在開始驗證。

建立UserController

namespace FluentValidationExample.Controllers
{
    public class UserController : Controller
    {
        // GET: User
        public ActionResult Index()
        {
            return View();
        }

        [HttpPost]
        public ActionResult Index(User data)
        {
            if (ModelState.IsValid)
            {
                // To do your application logic
            }

            return View(data);
        }
    }
}

Scaffold 建立頁面

用Scaffold快速建立驗證頁面。

驗證結果

成功不會出現錯誤。

參考資料

SourceCode

FluentValidation官方文檔解讀

ValidatorFactory參考

【C#】強型別(Strong Type) 的Session用法

平常有時候用到Session時都是弱型別,管理很不易,當然Session能少用比較好。

找到資料想分享給大家強型別的Session用法,該作者是用單例模式(Singleton),主要管理Session類別如下。

SessionInfo

public class SessionInfo<Subclass>
    where Subclass : SessionInfo<Subclass>, new()
{
    private static string Key
    {
        get { return typeof(SessionInfo<Subclass>).FullName; }
    }

    private static Subclass Value
    {
        get { return (Subclass)HttpContext.Current.Session[Key]; }
        set { HttpContext.Current.Session[Key] = value; }
    }

    public static Subclass Current
    {
        get
        {
            var instance = Value;
            if (instance == null)
                lock (typeof(Subclass))
                {
                    instance = Value;
                    if (instance == null)
                        Value = instance = new Subclass();
                }

            return instance;
        }

    }
}

Model

public class Member : SessionInfo<Member>
{
    public string Name { get; set; }
    public int Age { get; set; }
}

設定值或取值

Member.Current.Name = "Vincent";
Member.Current.Age = 20;

string Name = Member.Current.Name;
int Age = Member.Current.Age;

就是這麼簡單,目前未發現有什麼缺點,確實不同使用者各Session互不影響。

參考資料

Source Code(Github)
Session with Style

【C#】平常處理 QueryString 範例

分享一些專案上我處理QueryString範例。

基本上我都是用NameValueCollection來解析,使用方法類似Dictionary,資料型態為<string, string>,不過比較特別地方是他Key值是可以重覆的,並不會有相同Key值而出錯。

擴充類別可自行加入常用工具。

範例畫面

Main

class Program
{
    static void Main(string[] args)
    {
        string url = $"https://www.google.com.tw";

        var nvc = new NameValueCollection
        {
            {"parameter1", "1" },
            {"parameter2", "two" },
            {"parameter3", "中文" }
        };

        // 可用Add方法加入<string, string>
        nvc.Add("parameter4", "測試");

        // ***【補充】***
        // 要在網頁上接受QueryString方法
        // NameValueCollection nvc = Request.QueryString;

        // 【Demo1】網址 + QueryString參數
        Console.WriteLine($"-------------目標網址-------------");
        Console.WriteLine($"{url}{nvc.ToQueryString()}");
        Console.WriteLine($"---------------------------------");
        Console.WriteLine();

        // 接收QueryString參數
        var collection = HttpUtility.ParseQueryString($"{nvc.ToQueryString()}");

        // 拆解QueryString參數
        var all = from key in nvc.AllKeys
                  from value in nvc.GetValues(key)
                  select ($"{key}:{value}");

        // 【Demo2】
        Console.WriteLine($"-------------解析QueryString全部參數-------------");
        Console.WriteLine($"{string.Join("", all)}");
        Console.WriteLine($"-----------------------------------------------");
        Console.WriteLine();

        // 【Demo3】
        Console.WriteLine($"-------------解析QueryString單參數-------------");
        Console.WriteLine($"{collection.GetKey(0)}:{collection["parameter1"]}");
        Console.WriteLine($"---------------------------------------------");
        Console.WriteLine();

        Console.Read();
    }
}

NameValueCollection擴充

public static class Extensions
{
    public static string ToQueryString(this NameValueCollection nvc)
    {
        var segments = from key in nvc.AllKeys
                       from value in nvc.GetValues(key)
                       select 
                       $"{WebUtility.UrlEncode(key)}={WebUtility.UrlEncode(value)}";

        return $"?{string.Join("&", segments)}";
    }
}

Github可下載原始碼。

【C#】Dapper CRUD 範例

分享一些自己Dapper的CRUD範例,把它包成一個Class供使用,可以選擇連接資料庫類型及字串,可自行擴充,目前足以應付一些平常性工作,大家參考看看囉。

實體化後可以選擇選料庫類型及連線字串

DapperHelper d = new DapperHelper(Dialect.SqlLite, ConnectionSelecter.SqlLiteLocalDb);

範例畫面

結論

Github可下載原始碼。Dapper確實好用,有著強型別且寫法簡單,大家可以嘗試看看。

【ASP.NET】Ebill 金流串接 全國繳費網 簡單範例

問題

前一陣子幫同事接的小案子,主要說明檔很不清楚他們如何串接,因此寫了這篇,會講解接收與傳送的方法。

Ebill接收方法

用一般頁面繼承IHttpHandler,主要能拿到FromStream就可以了,是Base64編碼,接到值請自行處理。對了,Webconfig也要新增handlers項目。範例建立一個query.aspx頁面。

Web.config

  <system.webServer>
    <handlers>
      <add name="QueryHandler" verb="*" path="query.aspx" type="ebill.Query"/>
    </handlers>
  </system.webServer>

接收與傳送資料(query.aspx.cs)

public class Query : IHttpHandler
    {
        void IHttpHandler.ProcessRequest(HttpContext context)
        {
            try
            {
                string ReceivedData = GetFromInputStream(context);
                                context.Response.Clear();
                
                if (!string.IsNullOrEmpty(ReceivedData))
                {
                    // ReceivedData可以用Base64解密後進行資料處理,在Ebill叫8010、8020等
                    // Ebill會回來抓你Write值
                    context.Response.Write(ReceivedData);
                }
            }
            catch (Exception ex)
            {
                context.Response.Write(ex.Message);
            }
        }

        bool IHttpHandler.IsReusable
        {
            get { return true; }
        }

        private static string GetFromInputStream(HttpContext context)
        {
            var reader = new StreamReader(context.Request.InputStream);

            return reader.ReadToEnd();
        }
    }

Base64加解密

    /// <summary>
    /// Base64 編碼
    /// </summary>
    /// <param name="plainText">字串</param>
    /// <returns></returns>
    public static string Base64Encode(string plainText)
    {
        var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
        return Convert.ToBase64String(plainTextBytes);
    }

    /// <summary>
    /// Base64 解碼
    /// </summary>
    /// <param name="base64EncodedData">資料</param>
    /// <returns></returns>
    public static string Base64Decode(string base64EncodedData)
    {
        var base64EncodedBytes = Convert.FromBase64String(base64EncodedData);
        return Encoding.UTF8.GetString(base64EncodedBytes);
    }

結論

只探討傳送或接收的方法,資料接收到後照規格傳送資料,剩下就不難囉 : )

【C#】IF與Dictionary的Mapping比較

問題

在寫程式中常遇到Mapping,而一開始我都是用IF、Switch等來比較,會發現程式碼非常都長且不優雅。寫了LeetCode後一些集合類型都需要用上。以下例子只是講解if與dictionary兩者比較,當然沒做太多的例子呈現,所以要用哪個方法請自行判斷,方法很多種但需要用在對的事情上 : )

IF與Dictionary比較案例

情境呢,我們就說說例如數字[1]轉國字[一]吧,當然這資料比較算少,比較沒有說服力。在現實世界裡,會遇到龐大資料,這裡我就不說用哪個集合方法比較好,而是遇到什麼樣的事情在做出相對應方法。主要想表達兩者優雅度。

Main

    static void Main(string[] args)
    {
        string res = string.Empty;
        int target = 9;

        Stopwatch sw = new Stopwatch();
        sw.Reset();
        sw.Start();
        res = IfSolution(target);
        sw.Stop();
        string IfSecond = sw.Elapsed.TotalMilliseconds.ToString();

        sw.Reset();
        sw.Start();
        res = DictionarySolution(target);
        sw.Stop();
        string DictionarySecond = sw.Elapsed.TotalMilliseconds.ToString();

        Console.WriteLine($"If總共花費{IfSecond}秒找到{target}({res})");
        Console.WriteLine($"Dictionary總共花費{DictionarySecond}秒找到{target}({res})");
        Console.Read();
    }

IfSolution

    public static string IfSolution(int num)
    {
        if (num == 0) return "零";
        if (num == 1) return "一";
        if (num == 2) return "二";
        if (num == 3) return "三";
        if (num == 4) return "四";
        if (num == 5) return "五";
        if (num == 6) return "六";
        if (num == 7) return "七";
        if (num == 8) return "八";
        if (num == 9) return "九";

        return string.Empty;
    }

DictionarySolution

    public static string DictionarySolution(int num)
    {
        var dict = new Dictionary<int, string>()
        {
             {0, "零"},{1, "一"},{2, "二"},{3, "三"},{4, "四"},
             {5, "五"},{6, "六"},{7, "七"},{8, "八"},{9, "九"}
        };

        string res = string.Empty;
        dict.TryGetValue(num, out res);

        return res;
    }

執行五次所花費時間

If總共花費0.2695秒找到9(九)
Dictionary總共花費0.3656秒找到9(九)

If總共花費0.3785秒找到9(九)
Dictionary總共花費0.2875秒找到9(九)

If總共花費0.1898秒找到9(九)
Dictionary總共花費0.2199秒找到9(九)

If總共花費0.2701秒找到9(九)
Dictionary總共花費0.3236秒找到9(九)

If總共花費0.2346秒找到9(九)
Dictionary總共花費0.1968秒找到9(九)

結論

兩者比較看來還是傾向使用Dictionary方法,因資料小看不出差異點,實驗數據看看即可。

但越大資料上Dictionary會比較好,在查找效率上是O(1),Dictionary內部結構上也是採用HashTable方式。

Step by step(2) .Net Web API 2 撰寫 LineBot(Webhook) (使用套件)

續上篇Step by step(1) .Net Web API 2 撰寫 LineBot(Webhook) (使用套件)

看過後應該會對LineBot應用技巧會有點感覺,這篇會著重實作開發技巧以及新增幾個Action等介紹。

另外,Line似乎要把商業用途與開發分更明確,目前已經沒有Line Business Center,Messaging API申請方式有所異動,但其實申請方法差不多,請到LineDevelopers申請。

文章概要

1. 事件類別
   1.1 Common Fields
   1.2 Event Type
   1.3 Message Event
   1.4 程式範例
2. 實作功能
   2.1 上傳圖片
   2.2 DatetimePickerAction
3. 結論
4. 參考網址

1. 事件類別

當一個事件類型(Type)的判斷,例如:使用者(User)發了一個對話(Message),這個對話可能是文字(Text)、可能是圖片(Image)。

從上面例子來看我們知道類型有User、Message、(Text or Image),有這些型態可以定義要做什麼事情。

簡而言之,就是分析使用者的Event,然後在從Event內的Type讓程式去做某些事情。

建議:可把這些Type變成列舉(Enum)來做判斷,程式碼更好閱讀。

1.1 Common Fields

user:使用者
group:群組
room:房間

1.2 Event Type

message:訊息事件 (此事件還有一層如1.3介紹)
follow:使用者加入機器人為好友事件
unfollow:使用者封鎖機器人為好友事件
join:機器人加入群組事件
leave:機器人離開群組事件
postback:使用者透過Template Message回應的事件
beacon:透過 LINE Beacon 所觸發的事件

補充:什麼是LINE Beacon?

1.3 Message Event

text:文字
image:圖片
video:影片
audio:聲音
location:位置資訊
sticker:貼圖

1.4 程式範例

如何知道Common Fields、Event Type、Message Event?

// 接收Json資料
string postData = Request.Content.ReadAsStringAsync().Result;
var ReceivedMessage = Utility.Parsing(postData);

// CommonFields
string CommonFieldsType = ReceivedMessage.events.FirstOrDefault().source.type;

// EventType
string EventType = ReceivedMessage.events.FirstOrDefault().type;

// MessageEvent
string MessageEventType = ReceivedMessage.events.FirstOrDefault().message.type;

假設使用者發出"test"文字訊息,接收Type程式範例如下:

string postData = Request.Content.ReadAsStringAsync().Result;
var ReceivedMessage = Utility.Parsing(postData);
Bot bot = new Bot(ChannelAccessToken);
string Lineid = ReceivedMessage.events.FirstOrDefault().source.userId;

string CommonFieldsType = ReceivedMessage.events.FirstOrDefault().source.type;
string EventType = ReceivedMessage.events.FirstOrDefault().type;
string MessageEventType = ReceivedMessage.events.FirstOrDefault().message.type;

bot.PushMessage(Lineid, $"CommonFields:{CommonFieldsType}");
bot.PushMessage(Lineid, $"EventType:{EventType}");
bot.PushMessage(Lineid, $"MessageEvent:{MessageEventType}");

2. 實作功能

上篇沒說到的一些實作,未來有新的實作或技巧都會更新上來。

2.1 上傳圖片

當使用者與Bot對話會有一個ContentID,再利用此ContentID反查使用者所發送的資訊。

在程式範例當中先抓取ContentID,接著接收圖片並上傳至空間,在發送圖片網址給使用者。

if(ReceivedMessage.events.FirstOrDefault().message.type =="image")
{
    string ContentID = ReceivedMessage.events.FirstOrDefault().message.id.ToString();
    // 取得使用者bytedata
    byte[] filebody = Utility.GetUserUploadedContent(ContentID, ChannelAccessToken);
    string filename = $"/image/{Guid.NewGuid()}.png";
    var path = HttpContext.Current.Request.MapPath(filename);
    File.WriteAllBytes(path, filebody);

    bot.PushMessage(Lineid, "您上傳圖片為下方:");
    bot.PushMessage(Lineid, new Uri($"https://{HttpContext.Current.Request.Url.Host}{filename}"));
}

2.2 DatetimePickerAction

讓使用者選擇時間。當使用者選擇日期後再從postback.Params去解析使用者選擇的時間,為Postback Event。

詳細格式一定要看Line Developers

節錄自Line Developers的Datetime picker action。

var Template = new List<TemplateActionBase>();

Template.Add(new DateTimePickerAction()
{
    label = "test", // 標籤文字
    mode = "date", // 有三個mode:date、time、datetime
    data = "Postbackdata", // Postback資料
    initial = "2017-09-28", // 時間初始值
    max = "2017-12-31", // 時間最大值
    min = "2017-01-01" // 時間最小值
});

var ButtonsTemplate = new ButtonsTemplate()
{
    thumbnailImageUrl = new Uri("https://pics.iclope.com/news/test-cigarette-electronique.jpg"),
    title = "標題",
    text = "內容文字",
    altText = "當不支援裝置的文字",
    actions = Template
};

// 接收Postback資料
if (ReceivedMessage.events.FirstOrDefault().type == "postback")
{
    //判斷data為"Postbackdata"
    if (ReceivedMessage.events.FirstOrDefault().postback.data == "Postbackdata")
    {
        // 使用者選擇時間
        string Date = ReceivedMessage.events.FirstOrDefault().postback.Params.date;
        bot.PushMessage(Lineid, $"您選擇日期為{Date}");
        return Ok();
    }
}

bot.PushMessage(Lineid, ButtonsTemplate);

3. 結論

LineBot功能越來越多,面對商業行為受到更大的挑戰,往後有新功能或技巧會上來更新 : )

GitHub

4. 參考網址

Line developers

Step by step(1) .Net Web API 2 撰寫 LineBot(Webhook) (使用套件)

先感謝董大偉(David)老師提供LineBot套件,在操作上得心應手,講解非常細心 : )

文章概要

1. 註冊LineBot
2. WebAPI設定
   2.1 前置作業
   2.2 Webhook服務空間設定(以Azure為例)
3. Webhook程式撰寫
   3.1 ReplyMessage
   3.2 PushMessage
   3.3 ButtonsTemplate
   3.4 CarouselTemplate
   3.5 ConfirmTemplate
   3.6 Channel Secret應用
4. 結論
5. 參考網址

1. 註冊LineBot

先到Line Business Center網站,登入您的Line帳號。

(Line Business Center 2017/09/21關閉服務,會用別的服務來代替,屆時在改變方法註冊)

我們在[帳號清單]底下建立一個Messaging API帳號,點選[開始使用Developer Trial],剩下資訊請自行設定。



在帳號清單底下可以看到剛剛建立的項目,點選[LINE@ MANAGER],剩下資料可自行設定,主要把[Messaging API設定]打開。


會警告說開啟Bot將會消失一對一的功能,本來就是要已開發Bot為主,設定上請跟以下圖片一樣,我們需要用Webhook傳訊。

在回到[帳號清單]可以發現有新的選項[Line Developers],點選,接著可以看到Webhook設定,那麼最主要[Channel Access Token],點選[ISSUE]將會產生新的一組,務必別外流,程式面需要用到該Token,當然更嚴謹需要用到[Channel Secret]之後一併說明。請先自行加入該Bot為好友。


2. WebAPI設定

2.1 前置作業

我是用VS2015示範,新增空白Web API,加入一個Controller(範例命名名稱為LineBotController),在專案屬性裡設定部分,把[ChannelAccessToken]與[ChannelSecret]值做設定,後面的值請自行更改。





在套件部分請在nuget安裝好。

2.2 Webhook服務空間設定(以Azure為例)

到這裡為止應該都是簡單的設定,很多人應該會卡在這項,Line為了安全性,Webhook需要HTTPS服務空間,在此範例我是用Azure免費的就夠測試了,等之後專案開發在去考慮服務空間問題。

至於哪裡還有提供HTTPS我給幾個選項:1. Ngrok 2. Letsencrypt

其中Ngrok比較容易實現,剩下可能再自行找找或自己有憑證先行架設好。

Azure範例:

為了測試Webhook回應成功,在LineBotController底下程式碼如下。

public class LineBotController : ApiController
{
    [HttpPost]
    public IHttpActionResult POST()
    {
        try
        {
            return Ok();
        }
        catch
        {
            return Ok();
        }
    }
}

Azure自行註冊好,先專案點選發行(如果想更新程式點選發行,等於上傳到Azure空間),剩下自行設定,不在描述,Azure已提供最簡單的方式來做發行 : ) 這邊如果講解不清楚可以去找找資料,Webhook網址應該會是https:{YourDomainName}/{api}/{ControllerName}。



接著,回到Line Developers後台,把Webhook服務網址貼上再點選[VERIFY],如果設定都無誤的話會是成功的狀態。

到目前為止都是設定,設定都好的話接下來就是Webhook程式撰寫 : )

3. Webhook程式撰寫

程式撰寫部分會先從簡單的開始,逐一做介紹,補充一下,您的LineId並非唯一,LineId可能在不同的Bot上所對應的ID不一定相同,這點需要注意,另外有些功能電腦版不支援,例如Template功能等。

例如:
UserA對於Bot A的LineId為A
UserA對於Bot B的LineId為B

3.1 ReplyMessage

這主要可以回覆使用者訊息,當使用者發出訊息時候利用接收到的ReplyToken來做回覆。

public class LineBotController : ApiController
{
    [HttpPost]
    public IHttpActionResult POST()
    {
        string ChannelAccessToken = Properties.Settings.Default.ChannelAccessToken;

        try
        {
            // 解析Json資料
            string postData = Request.Content.ReadAsStringAsync().Result;
            var ReceivedMessage = Utility.Parsing(postData);
            Bot bot = new Bot(ChannelAccessToken);

            string Lineid = ReceivedMessage.events.FirstOrDefault().source.userId;
            // 得到使用者資訊 (可以知道使用者狀態消息、暱稱等)
            var UserInfo = bot.GetUserInfo(Lineid);
            
            // 1.【ReplyMessage】
            string Message = "";
            Message = $"{UserInfo.displayName} 你好,你說了:{ReceivedMessage.events.FirstOrDefault().message.text}";
            Utility.ReplyMessage(ReceivedMessage.events.FirstOrDefault().replyToken, Message, ChannelAccessToken);

            return Ok();
        }
        catch
        {
            return Ok();
        }
    }
}

3.2 PushMessage

PushMessage非常簡單,只要知道對方LineId即可發出訊息,也可以發出貼圖訊息,如果要知道有那些貼圖可用,請參考此網站

public class LineBotController : ApiController
{
    [HttpPost]
    public IHttpActionResult POST()
    {
        string ChannelAccessToken = Properties.Settings.Default.ChannelAccessToken;

        try
        {
            // 解析Json資料
            string postData = Request.Content.ReadAsStringAsync().Result;
            var ReceivedMessage = Utility.Parsing(postData);
            Bot bot = new Bot(ChannelAccessToken);

            string Lineid = ReceivedMessage.events.FirstOrDefault().source.userId;
            // 得到使用者資訊 (可以知道使用者狀態消息、暱稱等)
            var UserInfo = bot.GetUserInfo(Lineid);
            
            // 2.【PushMessage】
            bot.PushMessage(Lineid, "PushMessage");
            bot.PushMessage(Lineid, 1, 113);

            return Ok();
        }
        catch
        {
            return Ok();
        }
    }
}

3.3 ButtonsTemplate

ButtonsTemplate呈現,主要讓使用者不用用輸入文字方式來進行動作,以點選Button方式進行動作。

注意:Template上選單的圖片網址需為HTTPS

這些動作會有URI、Postback、Message等,我先把這些方法寫成介面,程式碼可以更簡潔,等等看應用後會更清楚ButtonsTemplate再做什麼。

interface ITemplateAction
{
    void Message(string label, string text);
    void Uri(string label, string uri);
    void Postback(string label, string data, string text);
}
public class AddAction : ITemplateAction
{
    List<TemplateActionBase> templateactions = new List<TemplateActionBase>();

    public AddAction(List<TemplateActionBase> templateactions)
    {
        this.templateactions = templateactions;
    }

    /// <summary>
    /// 訊息
    /// </summary>
    /// <param name="label">按鈕文字</param>
    /// <param name="text">文字</param>
    public void Message(string label, string text)
    {
        templateactions.Add(new MessageActon()
        { label = label, text = text });
    }

    /// <summary>
    /// PostBack
    /// </summary>
    /// <param name="label">按鈕文字</param>
    /// <param name="data">PostBack Data</param>
    /// <param name="text">文字</param>
    public void Postback(string label, string data, string text)
    {
        templateactions.Add(new PostbackActon()
        { label = label, data = data, text = text == "" ? null : text });
    }

    /// <summary>
    /// URI
    /// </summary>
    /// <param name="label">標籤</param>
    /// <param name="uri">網址</param>
    public void Uri(string label, string uri)
    {
        templateactions.Add(new UriActon()
        { label = label, uri = new Uri(uri) });
    }
}

如此一來要增加Action更容易了,介紹一下Action所需用途。

Message:要使用者說出某些對話。
URI:可以連結該網址。
Postback:例如您想要在背後運行就要用此方式,當然跟Message也很像也可以發出一個對話,但通常我都拿來背後運行比較多,也就是說,使用者點選Postback的Action,後端會接送一串Postback文字,看這些文字要做什麼事情由您決定。

// 3.【ButtonsTemplate】
//接收PostBack資料
if (ReceivedMessage.events.FirstOrDefault().postback != null)
{
    string PostBack = ReceivedMessage.events.FirstOrDefault().postback.data;
    bot.PushMessage(Lineid, $"我收到你postback資料為{PostBack}");
}
else
{
    var Template = new List<TemplateActionBase>();
    AddAction action = new AddAction(Template);

    action.Message("文字標籤", "點選後發送的文字");
    action.Uri("連到Google", "https://www.google.com.tw");
    action.Postback("Postback1", "Postback1", null);
    action.Postback("Postback2", "Postback2", "使用者點選後所要呈現的文字");

    var ButtonsTemplate = new ButtonsTemplate()
    {
        thumbnailImageUrl = new Uri("https://pics.iclope.com/news/test-cigarette-electronique.jpg"),
        title = "標題",
        text = "內容文字",
        altText = "當電腦版看到的文字",
        actions = Template
    };

    // 發送ButtonsTemplate
    bot.PushMessage(Lineid, ButtonsTemplate);
}

3.4 CarouselTemplate

算是ButtonsTemplate延伸,其實就是多選單而已,Action都一樣。但值得注意的是,我在開發期間發現一個問題,也就是說選單規定是要同一列的,最多五個選單,但是列表的Action都要一致,否則會出錯。

例如:選單 * Action數量 其中Action數量要一致,選單不能大於五個。

// 4.【CarouselTemplate】
List<TemplateActionBase> ta1 = new List<TemplateActionBase>();
List<TemplateActionBase> ta2 = new List<TemplateActionBase>();
List<TemplateActionBase> ta3 = new List<TemplateActionBase>();

AddAction act1 = new AddAction(ta1);
AddAction act2 = new AddAction(ta2);
AddAction act3 = new AddAction(ta3);

//需要一致Action數量
act1.Message("Test1", "Test1");
act1.Message("Test2", "Test2");

act2.Message("Test3", "Test3");
act2.Message("Test4", "Test4");

act3.Message("Test5", "Test5");
act3.Message("Test6", "Test6");

var ct1 = new Column()
{
    title = "選單一",
    text = "選單一說明",
    thumbnailImageUrl = new Uri("https://pics.iclope.com/news/test-cigarette-electronique.jpg"),
    actions = ta1
};

var ct2 = new Column()
{
    title = "選單二",
    text = "選單二說明",
    thumbnailImageUrl = new Uri("https://pics.iclope.com/news/test-cigarette-electronique.jpg"),
    actions = ta2
};

var ct3 = new Column()
{
    title = "選單三",
    text = "選單三說明",
    thumbnailImageUrl = new Uri("https://pics.iclope.com/news/test-cigarette-electronique.jpg"),
    actions = ta3
};

List<Column> CarouselTemplateSub = new List<Column>();

CarouselTemplateSub.Add(ct1);
CarouselTemplateSub.Add(ct2);
CarouselTemplateSub.Add(ct3);

var CarouselTemplate = new CarouselTemplate()
{
    altText = "當電腦版看到的文字",
    columns = CarouselTemplateSub
};

bot.PushMessage(Lineid, CarouselTemplate);

3.5 ConfirmTemplate

像我們網頁上的確認視窗,有同意跟不同意選項,當然Action可以自己自定義,應用面很廣,很常用的Template。

// 5.【ConfirmTemplate】
if (ReceivedMessage.events.FirstOrDefault().postback != null)
{
    string PostBack = ReceivedMessage.events.FirstOrDefault().postback.data;
    bot.PushMessage(Lineid, $"{PostBack}");
}
else
{
    var ConfirmActon = new List<TemplateActionBase>();

    AddAction action = new AddAction(ConfirmActon);
    action.Postback("是", "您選擇是", null);
    action.Postback("不是", "您選擇不是", null);

    ConfirmTemplate ConfirmTemplate = new ConfirmTemplate();
    ConfirmTemplate.text = "內容文字";
    ConfirmTemplate.altText = "電腦版所看到文字";
    ConfirmTemplate.actions = ConfirmActon;

    bot.PushMessage(Lineid, ConfirmTemplate);
}

3.6 Channel Secret應用

我在這篇文章看到後才知道Channel Secret應用,雖然Line是HTTPS資料傳輸,也要防範CSRF攻擊,Line傳給Webhook資料時會在Header夾帶一個X-Line-Signature簽名,這個Signature是由Channel Secret作為私鑰,與Request Body進行HMAC-SHA256進行加密計算。只有開發人員能夠驗證這組X-Line-Signature是否有效。詳細可看Line Developer文件。

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(Properties.Settings.Default.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;
    }
}

自己定義好後Signature AuthorizeAttribute,只要在進入這Controller之前先檢查X-Line-Signature是否符合,如果不對會回應錯誤,反之,進入該Controller執行。

[HttpPost]
[Signature]
public IHttpActionResult POST()
{

}

4. 結論

此篇介紹是比較常見到的功能,當然還有更多功能還沒介紹,套件上董大偉老師一只有做更新,在開發上可以更多的應用。不過在開發上會有很多問題存在,例如最常見的如何知道使用者現在狀態是什麼? 例如處理問卷就會非常瑣碎,一來一往增加許多複雜度,必須記住使用者選項、狀態等,主要沒有網頁上的Session等幫你記住任何值,所有狀態、命令等都先存在資料庫,所以越多層的問答會非常麻煩,必須一層一層的拆,盡量依簡單方式來進行。ChatBot非常熱門,應用面很廣,甚至可以請假、點餐、消費等方式,算是一個溝通介接的窗口,相信往後ChatBot發展會更深入。

Source Code:GitHub

5. 參考網址

LINE Developer 官方文件
.NET Walker
一步一步用 .NET Web API 撰寫 LINE Webhook (LINEBot)