iT邦幫忙

8

[C#][ASP.NET] Web API 開發心得 (5) - 使用 Filter 統一 API 的回傳格式和例外處理

今天要介紹的是 Filter,這是我非常喜歡的功能,Filter 有點像管道 Middleware 的延伸,會在管道結束後執行,Filter 的作用是,可在 Action 執行前執行後對 Request 進行加工處理,我們可以把一些通用的程式邏輯抽離,例如驗證、例外處理和修改回傳內容等等,在透過 Attribute 掛載到 Action 上,如此做可使 Action 更專注於本身的工作 關注點分離,獨立的 Filter 模組更易於抽換和擴充,提高程式的內聚力和降低耦合度,讓程式更好維護。

而在 Webform 時代我們如果想達到類似的功能,可以使用傳統的 ASP.NET 管道模型,也就是 Middleware 的前身,在 ASP.NET 的管道中,Request 會經過多個 Module 最後才會抵達 Handler,Handler 就是我們比較常寫的 ASPX 和 ASHX,Handler 結束後會再通過原來的 Module 原路返回,因此我們可以利用 Module 來對 Request 進行加工處理,不過沒有辦法像 Filter 一樣靈活,這篇因為重點在 Filter 所以就不對 Module 多做介紹。

接下來要進入本篇的重點,在開發 API 時我們希望所有的 API 能有一致的輸出格式,這樣使用者就不需要為每個 API 去做不同的接收方式,讓 API 使用起來更方便,Filter 可以分為下面三類,後面我會利用這三種不同的 Filter 來統一 API 的輸出格式。

  1. AuthorizationFilter: 在所有 Filter 之前執行,用於驗證 Request 是否合法。
  2. ActionFilter: 裡面有兩個方法分別對應 Action 執行前和執行後。
  3. ExceptionFilter: 會在發生異常時執行。

Filter 流程圖:
https://ithelp.ithome.com.tw/upload/images/20180405/20106865PwacCSMKtL.jpg

上圖藍色箭頭為 Request 經過 Filter 的正常流程,首先會經過 AuthorizationFilter,驗證使用者資訊是否合法,接著通過 ActionFilter,最後到達 Action,結束後在經過 ActionFilter 返回,AuthorizationFilter 只有進來時才會執行,而橘色箭頭是,如果在 ActionFilter 或 Action 內程式出現異常 Exception,那麼就會被 ExceptionFilter 攔截做異常的處理,這裡一定有人會問如果在 AuthorizationFilter 內出現異常呢,沒錯這裡是需要注意的地方,我們不應該在 AuthorizationFilter 拋出異常,因為它不會被 ExceptionFilter 攔截處理。

統一的輸出格式

新增 ResultViewModel 類別,作為我們所有 API 的統一介面,
類別內有下面三個屬性:

  1. success: 代表請求是否執行成功。
  2. msg: 存放異常的錯誤訊息。
  3. data: 存放請求成功後回傳的資料。

程式碼:

namespace ViewModel
{
    public class ResultViewModel
    {
        public bool success { get; set; }

        public string msg { get; set; }

        public object data { get; set; }
    }
}

新增 ResultAttribute 類別,繼承 ActionFilter 覆寫 OnActionExecuted 方法,該 Filter 的作用是包裝 Action 回傳的資料,將資料放入 ResultViewModel 的 data 屬性內,再回傳出去,這個 Filter 可以搭配 IgnoreResult Attribute 使用,如果我們希望有些 Controller 或 Action 的回傳資料不要經過包裝處理,例如檔案下載,那麼可以掛上 IgnoreResult 就會忽略這些 Action。

程式碼:

namespace Filters
{
    public class ResultAttribute : ActionFilterAttribute
    {
        public override void OnActionExecuted(HttpActionExecutedContext actionExecutedContext)
        {
            if (actionExecutedContext.Exception != null)
            {
                return;
            }

            var ignoreResult1 = actionExecutedContext.ActionContext.ActionDescriptor.GetCustomAttributes<IgnoreResultAttribute>().FirstOrDefault();
            var ignoreResult2 = actionExecutedContext.ActionContext.ControllerContext.ControllerDescriptor.GetCustomAttributes<IgnoreResultAttribute>().FirstOrDefault();
            if (ignoreResult1 != null || ignoreResult2 != null)
            {
                return;
            }

            var objectContent = actionExecutedContext.Response.Content as ObjectContent;

            var data = objectContent?.Value;

            var result = new ResultViewModel
            {
                success = true,
                data = data
            };

            actionExecutedContext.Response = actionExecutedContext.Request.CreateResponse(result);
        }
    }
}

程式碼:

namespace Filters
{
    public class IgnoreResultAttribute : Attribute
    {
    }
}

例外處理

新增 CustomException 類別,有時候如果我們不希望回傳的錯誤訊息,包含了敏感的資訊,例如執行SQL語法出錯時,會回傳部分的SQL語句,可能包含了欄位資訊或資料等等,因此就可以利用 CustomException 來識別這個 Exception 是不是已經被我們處理過,可否傳到外部去。

程式碼:

namespace Exceptions
{
    public class CustomException : Exception
    {
        public CustomException(string message) : base(message)
        {

        }
    }
}

新增 ExceptionAttribute 類別,繼承 ExceptionFilter,該 Filter 會攔截異常,將錯誤訊息填入 ResultViewModel 的 msg 屬性內,然後將包裝後的異常回傳,不過這裡我並沒有判斷 Exception 是否是 CustomException,因為我還是習慣將所有異常訊息都回傳,這個可以視專案需求調整。

程式碼:

namespace Filters
{
    public class ExceptionAttribute : ExceptionFilterAttribute
    {
        public override void OnException(HttpActionExecutedContext actionExecutedContext)
        {
            var result = new ResultViewModel
            {
                success = false,
                msg = actionExecutedContext.Exception.Message
            };

            actionExecutedContext.Response = actionExecutedContext.Request.CreateResponse(result);
        }
    }
}

權限控制

新增 CustomAuthorizeAttribute 類別,繼承 AuthorizationFilter,功能很單純,僅用來判斷使用者是否有登入,這裡有使用到 上一篇 實作的 UserManager,而更細的身分判斷我通常會寫在 Action 內,Filter 只做第一層最簡單的防護,CustomAuthorize 還可以搭配原 Web API 內建的 AllowAnonymous Attribute 使用,掛上這個 Attribute 的 Controller 或 Action 將不會執行驗證權限的動作,代表這是個公開的 API,任何人都可以存取,Filter 內我有用 try catch 包住所有程式,就如同上面提到的,我們不應該在 AuthorizationFilter 內拋出異常,因為它不會被 ExceptionFilter 捕捉。

程式碼:

namespace Filters
{
    public class CustomAuthorizeAttribute : AuthorizationFilterAttribute
    {
        protected readonly UserManager _userManager;
        public CustomAuthorizeAttribute()
        {
            _userManager = new UserManager();
        }

        public override void OnAuthorization(HttpActionContext actionContext)
        {
            try
            {
                if (actionContext.ActionDescriptor.GetCustomAttributes<AllowAnonymousAttribute>().Count > 0)
                {
                    return;
                }
                if (actionContext.ControllerContext.ControllerDescriptor.GetCustomAttributes<AllowAnonymousAttribute>().Count > 0)
                {
                    return;
                }

                var user = _userManager.GetCurrentUser();
                if (user == null)
                {
                    throw new CustomException("沒有權限。");
                }
            }
            catch (Exception ex)
            {
                if (!(ex is CustomException))
                {
                    ex = new CustomException("權限驗證異常。");
                }

                var result = new ResultViewModel
                {
                    success = false,
                    msg = ex.Message
                };
                actionContext.Response = actionContext.Request.CreateResponse(result);
            }
        }
    }
}

如何使用

我會新增一個 BaseController 並在 Controller 掛上 CustomAuthorize,然後讓其它 Controller 繼承,這樣就可以讓所有的 API 受到最基本的權限驗證保護,而公開的 API 再加上 AllowAnonymous Attribute 關閉驗證,這樣做有個好處,可以防止未來新增 Action,或由別人來維護程式時忘記加上權限驗證,導致資料外洩,接著新增一個 TestFilterController 並掛上 Exception 和 Result 來測試一下各個 Filter 的功能。

程式碼:

namespace Api
{
    [CustomAuthorize]
    public class BaseController : ApiController
    {
        public BaseController()
        {

        }
    }
}
namespace Api
{
    [Result]
    [Exception]
    [RoutePrefix("api/testFilter")]
    public class TestFilterController : BaseController
    {
        //正常
        [HttpGet]
        [Route("getStudents_1")]
        public List<Student> GetStudents_1()
        {
            return CreateStudents();
        }

        //忽略權限驗證
        [AllowAnonymous]
        [HttpGet]
        [Route("getStudents_2")]
        public List<Student> GetStudents_2()
        {
            return CreateStudents();
        }

        //忽略 ResultFilter
        [IgnoreResult]
        [HttpGet]
        [Route("getStudents_3")]
        public List<Student> GetStudents_3()
        {
            return CreateStudents();
        }

        //拋出異常
        [HttpGet]
        [Route("getStudents_4")]
        public List<Student> GetStudents_4()
        {
            throw new CustomException("取得資料失敗");
        }
        
        private List<Student> CreateStudents()
        {
            return new List<Student>
            {
                new Student
                {
                    Id = 100,
                    Name = "小明"
                },
                new Student
                {
                    Id = 101,
                    Name = "小華"
                },
            };
        }
    }
    
    public class Student
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }
}

資料夾結構
https://ithelp.ithome.com.tw/upload/images/20180405/20106865Lq47GO5dp9.jpg

測試結果

未登入狀態下對四個 API 的測試結果:

正常: api/testFilter/getStudents_1
https://ithelp.ithome.com.tw/upload/images/20180405/20106865ki7e4NwwuD.jpg

忽略權限驗證: api/testFilter/getStudents_2
https://ithelp.ithome.com.tw/upload/images/20180405/20106865NgldgeyI4f.jpg

忽略 ResultFilter: api/testFilter/getStudents_3
https://ithelp.ithome.com.tw/upload/images/20180405/20106865jss9JUGrPZ.jpg

拋出異常: api/testFilter/getStudents_4
https://ithelp.ithome.com.tw/upload/images/20180405/20106865cu0nHGDYNz.jpg

登入狀態下對四個 API 的測試結果:

正常: api/testFilter/getStudents_1
https://ithelp.ithome.com.tw/upload/images/20180405/20106865GWZFtgHFxc.jpg

忽略權限驗證: api/testFilter/getStudents_2
https://ithelp.ithome.com.tw/upload/images/20180405/20106865oEPXpoHb1w.jpg

忽略 ResultFilter: api/testFilter/getStudents_3
https://ithelp.ithome.com.tw/upload/images/20180405/20106865CjvNqZKxKu.jpg

拋出異常: api/testFilter/getStudents_4
https://ithelp.ithome.com.tw/upload/images/20180405/201068657SNK15qaUO.jpg

結語

這篇我們使用 Filter 來統一 API 的回傳格式和例外處理,除了方便使用者使用外,程式邏輯的抽離讓 Action 能更專注於自己的工作,可自由組合的 Filter,讓 API 面對各種不同需求時有更大的彈性,且將結果的處理拉到 Action 之外,才不會破壞原來的寫法,依舊可由 Action 的回傳型態看出 API 回傳的資料結構,不會因為要統一格式,就造成 Action 都回傳 ResultViewModel,程式的可讀性變差。

今天就介紹到這裡,感謝大家觀看。

參考資料

使用Asp.Net MVC打造Web Api (16) - 統一輸入/出格式以及異常處理策略
使用Asp.Net MVC打造Web Api (20) - 整合AOP功能
[鐵人賽 Day14] ASP.NET Core 2 系列 - Filters


2 則留言

0
神Q超人
iT邦新手 2 級 ‧ 2018-04-05 21:56:52

連假第一po超用心的欸!!!
這一系列都可以學到超多東西/images/emoticon/emoticon32.gif

fysh711426 iT邦研究生 4 級‧ 2018-04-08 01:14:35 檢舉

大家互相學習!!
/images/emoticon/emoticon37.gif

0
Homura
iT邦高手 1 級 ‧ 2018-09-13 16:49:24

我今天弄MVC專案也想寫個自訂filter
結果System.Web.Http.Filters找不到在哪
記得API專案沒這問題...
後來查到答案是Nuget安裝Microsoft.AspNet.WebApi.SelfHost..

fysh711426 iT邦研究生 4 級‧ 2018-09-14 09:31:07 檢舉

如果是 MVC 的 Filter 好像不用另裝 Nuget
您裝的 Microsoft.AspNet.WebApi.SelfHost 是 Web API 1 的 Filter
這篇是使用 Web API 2 會需要這幾個

  • Microsoft.AspNet.WebApi
  • Microsoft.AspNet.WebApi.Owin
  • Microsoft.Owin.Host.SystemWeb

如果是 .NET Core 則不分 MVC 和 Web API 整合在一起了
真的是好多版本啊,哈哈哈
/images/emoticon/emoticon06.gif

Homura iT邦高手 1 級‧ 2018-09-14 09:35:15 檢舉

fysh711426
結果這是web api 1用的啊
怎麼覺得.Net framework套件非常混亂啊.../images/emoticon/emoticon06.gif
以為filter是基本功能
.NET Core感覺整裡的比較好的感覺/images/emoticon/emoticon16.gif

fysh711426 iT邦研究生 4 級‧ 2018-09-14 11:11:22 檢舉

.NET Core 的 Web API 好用很多,不習慣 1 和 2 的 Request 只能讀取一次,不過 .NET Core 還不成熟,不敢用在專案上。
/images/emoticon/emoticon16.gif

我要留言

立即登入留言