2015年12月20日 星期日

ASP.NET Web Api - Help Page

這個功能的主題其實有很多人都寫過了,不過為了之後的文章,所以還是要先寫出這一篇。

大家也都知道 ASP.NET Web Api 2 都已經有內建了 Help Page 的功能,這是一個可以產生對應 API 服務的線上文件產生器,所謂的產生並不是可以幫我們做出一份 Word 或是 PDF 檔,而是指將我們所開發的 API 服務相關的輸入、輸出、Resource 等資料經由 Help Page 的功能處理並建立好網頁,在網頁上去提供了這些 API 服務的相關資訊,以方便介接 API 服務的開發人員查看。

這一篇就來簡單地介紹如何在 ASP.NET Web Api 應用服務裡啟用這一個功能。

 


首先準備好一個 ASP.NET Web Api 的服務,這邊範例使用的資料庫是 Northwind(是的,又是北風,別再問為什麼都是用北風,因為我懶,要為範例建立一個合乎使用情境的資料庫是很傷腦筋的),這篇所使用的開發工具與應用程式與套件的版本如下:

Visual Studio 2013
.NET Framework 4.5
ASP.NET Web Api 2.2.3 ( 5.2.3 )
ASP.NET Web Api Help Page 5.2.3

 

新增 ASP.NET Web 應用程式

SNAGHTML803b609

選擇使用 Web API 範本(不需要使用驗證)

SNAGHTML8048c93

確認預設使用 Web  API 範本所建立的網站服務有 Help Page

image

另外開啟 packages.config,在裡面有 ASP.NET WebApi Help Page

image

新增 LocalDB 以及建立 Northwind 資料庫

Download Northwind and pubs Sample Databases for SQL Server 2000 from Official Microsoft Download Center

image

image

建立 Model(使用來自資料庫的 Code Fisrt,或是使用 Database First)

SNAGHTML8a6e3a8

image

 

接著就是建立 Web API 服務的 Controller,這邊只會建立兩個,一個是 CustomersController 另一個為 ProductsController,都是使用 Scaffold 建立,

SNAGHTML8bc7a54

SNAGHTML8bdf914

SNAGHTML8be5bc6

 

重新整理新建立的 CustomersController 與 ProductsController,記得要在 Controller 類別與各個公開的 Action 方法加上 Summary (XML 註解)

CustomersController.cs

using System.Data.Entity;
using System.Data.Entity.Infrastructure;
using System.Linq;
using System.Net;
using System.Web.Http;
using System.Web.Http.Description;
using HelpPageDemo.Models;
 
namespace HelpPageDemo.Controllers
{
    /// <summary>
    /// Customer API 項目.
    /// </summary>
    public class CustomersController : ApiController
    {
        private Northwind db = new Northwind();
 
        /// <summary>
        /// 取得所有 Customer 資料.
        /// </summary>
        /// <returns>IQueryable&lt;Customer&gt;.</returns>
        public IQueryable<Customer> GetCustomers()
        {
            return db.Customers;
        }
 
        /// <summary>
        /// 指定 ID 以取得 Customer 資料.
        /// </summary>
        /// <param name="id">The identifier.</param>
        /// <returns>IHttpActionResult.</returns>
        [ResponseType(typeof(Customer))]
        public IHttpActionResult GetCustomer(string id)
        {
            Customer customer = db.Customers.Find(id);
            if (customer == null)
            {
                return NotFound();
            }
 
            return Ok(customer);
        }
 
        /// <summary>
        /// 更新 Customer.
        /// </summary>
        /// <param name="id">The identifier.</param>
        /// <param name="customer">The customer.</param>
        /// <returns>IHttpActionResult.</returns>
        [ResponseType(typeof(void))]
        public IHttpActionResult PutCustomer(string id, Customer customer)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }
 
            if (id != customer.CustomerID)
            {
                return BadRequest();
            }
 
            db.Entry(customer).State = EntityState.Modified;
 
            try
            {
                db.SaveChanges();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!CustomerExists(id))
                {
                    return NotFound();
                }
                else
                {
                    throw;
                }
            }
 
            return StatusCode(HttpStatusCode.NoContent);
        }
 
 
        /// <summary>
        /// 新增 Customer.
        /// </summary>
        /// <param name="customer">The customer.</param>
        /// <returns>IHttpActionResult.</returns>
        [ResponseType(typeof(Customer))]
        public IHttpActionResult PostCustomer(Customer customer)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }
 
            db.Customers.Add(customer);
 
            try
            {
                db.SaveChanges();
            }
            catch (DbUpdateException)
            {
                if (CustomerExists(customer.CustomerID))
                {
                    return Conflict();
                }
                else
                {
                    throw;
                }
            }
 
            return CreatedAtRoute("DefaultApi", new { id = customer.CustomerID }, customer);
        }
 
        /// <summary>
        /// 刪除 Customer.
        /// </summary>
        /// <param name="id">The identifier.</param>
        /// <returns>IHttpActionResult.</returns>
        [ResponseType(typeof(Customer))]
        public IHttpActionResult DeleteCustomer(string id)
        {
            Customer customer = db.Customers.Find(id);
            if (customer == null)
            {
                return NotFound();
            }
 
            db.Customers.Remove(customer);
            db.SaveChanges();
 
            return Ok(customer);
        }
 
        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                db.Dispose();
            }
            base.Dispose(disposing);
        }
 
        private bool CustomerExists(string id)
        {
            return db.Customers.Count(e => e.CustomerID == id) > 0;
        }
    }
}

ProductsControllers.cs

using HelpPageDemo.Models;
using System.Data.Entity;
using System.Data.Entity.Infrastructure;
using System.Linq;
using System.Net;
using System.Web.Http;
using System.Web.Http.Description;
 
namespace HelpPageDemo.Controllers
{
    /// <summary>
    /// Product API 項目.
    /// </summary>
    public class ProductsController : ApiController
    {
        private Northwind db = new Northwind();
 
        /// <summary>
        /// 取得全部 Product 資料.
        /// </summary>
        /// <returns>HttpResponseMessage.</returns>
        public IQueryable<Product> GetProducts()
        {
            return db.Products;
        }
 
        /// <summary>
        /// 指定 id 以取得 Product 資料.
        /// </summary>
        /// <param name="id">The identifier.</param>
        /// <returns>HttpResponseMessage.</returns>
        [ResponseType(typeof(Product))]
        public IHttpActionResult GetProduct(int id)
        {
            Product product = db.Products.Find(id);
            if (product == null)
            {
                return NotFound();
            }
 
            return Ok(product);
        }
 
        /// <summary>
        /// 更新 Product.
        /// </summary>
        /// <param name="id">The identifier.</param>
        /// <param name="product">The product.</param>
        /// <returns>IHttpActionResult.</returns>
        [ResponseType(typeof(void))]
        public IHttpActionResult PutProduct(int id, Product product)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }
 
            if (id != product.ProductID)
            {
                return BadRequest();
            }
 
            db.Entry(product).State = EntityState.Modified;
 
            try
            {
                db.SaveChanges();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!ProductExists(id))
                {
                    return NotFound();
                }
                else
                {
                    throw;
                }
            }
 
            return StatusCode(HttpStatusCode.NoContent);
        }
 
        /// <summary>
        /// 新增 Product.
        /// </summary>
        /// <param name="product">The product.</param>
        /// <returns>IHttpActionResult.</returns>
        [ResponseType(typeof(Product))]
        public IHttpActionResult PostProduct(Product product)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }
 
            db.Products.Add(product);
            db.SaveChanges();
 
            return CreatedAtRoute("DefaultApi", new { id = product.ProductID }, product);
        }
 
        /// <summary>
        /// 刪除 Product.
        /// </summary>
        /// <param name="id">The identifier.</param>
        /// <returns>IHttpActionResult.</returns>
        [ResponseType(typeof(Product))]
        public IHttpActionResult DeleteProduct(int id)
        {
            Product product = db.Products.Find(id);
            if (product == null)
            {
                return NotFound();
            }
 
            db.Products.Remove(product);
            db.SaveChanges();
 
            return Ok(product);
        }
 
        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                db.Dispose();
            }
            base.Dispose(disposing);
        }
 
        private bool ProductExists(int id)
        {
            return db.Products.Count(e => e.ProductID == id) > 0;
        }
    }
}

 

XML 文件檔案

不過這樣還沒好喔,接下來的步驟就相當重要,剛才的步驟是加上 XML 註解,但是必須要產生一份 XML 文件檔案,這要在專案的屬性裡去選擇產生 XML 檔案,

image

記得將「XML 文件檔案」勾選起來,然後可以修改輸出文件的輸出位置,這邊我修改為「App_Data/XmlDocument.xml

 

調整 ~/Areas/HelpPage/App_Start/HelpPageConfig.cs

為了要讓 Help Page 可以讀取到輸出的 XML 文件檔案,就要將 ~/Areas/HelpPage/App_Start/HelpPageConfig.cs 裡的一行程式做點修改,

image

將 HelpPageConfig.cs 的第 37 行程式給解除註解

image

image

最主要是 XML 文件檔案的路徑與名稱要與專案屬性裡所設定的內容一致

image

image


 

瀏覽 Help Page

專案重新建置並執行後,在 Web Api 服務的網站路徑帶入 Help 就可以開啟 Help Page 的頁面,

例如:http://localhost:60900/Help

image

點選其中一個 API 服務就可以看到對應的輸入、輸出的內容

image

image

image

 

在我開發 ASP.NET Web API 服務的時候,一開始與 APP 開發團隊的文件溝通就不是用 Word 檔案也不是使用 Google Drive 的 Doc 文件(切記,絕對不要這麼做,因為用 Word 文件是最差勁的方式,對於開發人員來說是最不友善也最不容易管理的做法),我們是透過 JIRA Confluence 來做線上文件管理,API 的輸出與輸入規格都是在線上作編輯與制訂,這麼做的好處除了方便修改與共筆編輯外,最重要的是輸入的資料格式與輸出的內容是不會受到文字編輯器的影響而被修改(相信使用 Word 檔案來做文件管理的人,遇到 JSON 的雙引號就一定會有所感)。

但是使用了線上文件管理並不能根本解決文件與開發產出不一致的問題,因為 Web Api 開發人員所做出來的 Web Api 有時候還是會跟文件上是不一致的,這個時候總不能把程式拿出來一個一個去做比對,或是將程式一個個執行後再來做比對,無論是那一種方式都蠻耗費時間的,所以當 Web Api 執行結果或是輸入參數與當初所制訂好的文件規格出現不一致的時候,就可以拿 Help Page 的頁面去跟規格文件作比對,看看是那邊出現了問題。

在我所屬的開發團隊裡,Help Page 已經是一項標準且必備的一個功能,不必開啟程式也不必另外整理程式裡的規格,只要保持並養成要寫 Summary(XML 註解)的習慣,只要加上 ASP.NET Web Api Help Page 的功能,就可以隨時在線上瀏覽 Web Api 服務的文件。

 


如果你已經有使用了 ASP.NET Web API Help Page,但你希望可以在這個頁面上去執行 API 服務然後直接看結果的話,也可以參考使用「Simple Test Client to ASP,NET Web API Help Page」,但是我這邊並不會介紹如何去使用,因為之後會介紹另外一個套件「Swashbuckle - Swagger for WebApi」,這應該是下一代 Web Api 文件產生器的套件,除了一樣可以看 Web Api 服務的文件之外,還能夠直接在上面執行 Api 並看結果,在之後會藉上給大家。

有關 API 線上文件,真的不要再用 Word 文件檔案,也不要用 Google Doc,因為真的不好用,這邊我推薦各位可以使用「apiary

image

https://docs.apiary.io/

以下連結是別人公開的 API 文件,就是使用 apiary,在上面除了是當作一般的規格文件外,也可以直接在上面去做執行並取得結果,大家可以去看看做個瞭解,真的不要再用 Word 檔案或是 Google Doc 來做 Api 規格文件的管理了。

http://docs.gistfoxapi.apiary.io/

 

相關連結

Creating Help Pages for ASP.NET Web API | The ASP.NET Site

KingKong Bruce記事: ASP.NET Web API 文件產生器(1) - Help Page

Adding a simple Test Client to ASP.NET Web API Help Page - Yao's blog - Site Home - MSDN Blogs

 

以上

12 則留言:

  1. 謝謝分享~ 非常實用
    同時Swashbuckle 也很方便!!

    回覆刪除
  2. 你好,想請問關於XmlDocument的問題

    我的方案裡有Web API的專案,跟一個類別庫

    ViewModel的程式是放在類別庫裡,從Web API專案產生XmlDocument的時侯 不會有 ViewModel的註解內容

    我試過把ViewModel放到WebAPI專案下,產生XmlDocument的時侯 會有 ViewModel的註解內容

    請問要怎麼解決這個問題? 謝謝

    回覆刪除
    回覆
    1. 為何 ViewModel 會需要放在類別庫專案?

      刪除
    2. 因為我將Service層放到類別庫的專案裡,Service會丟ViewModel給Web API,所以把ViewModel也放到類別庫的專案裡

      刪除
    3. 服務層的輸出類別不應該也能夠被直接拿來作為 webapi 輸出的型別 (viewmodel 或稱為 outputmodel)
      一個 wepapi 的輸出類別,除非是很簡單的內容,通常一個輸出的資料有可能是要呼叫多個 service,
      然後這些叫用這些 service 所產出的結果,最後才組成要輸出的資料內容,

      如果什麼事情都是由一個 service 去做完,然後 webapi 的 controller 裡 action 方法只有去呼叫及使用那個 service 的產出,最後再拿這個 service 輸出物件直接往外丟,我會覺得這個 service 要麼就是太過於簡單,不然就是職責過多。

      在 mvc 或 webapi 的 controller action 方法要做的事情就是依據情境去呼叫一到多個 service,然後取得這些 service 的輸出,將這些 service 的輸出做組合,最後才回傳給使用端

      如果 repository 或 service 的輸出類別就可以直接作為外部輸出的內容,那麼就不需要去分層了,

      刪除
    4. 想請問一下 Kevin 大的意思是 ViewModel 應該在 Controller 中組成,
      而非在 Service 層就組好 ViewModel 後 return 給 Controller 嗎?
      本人最近遇到了一種狀況是不同 Project 的 Controller action(Web API) 提供的資源幾乎一樣,
      且因為這些 Project 是不同的站台以及客戶需求的關係必須每個站台都要有這些 API,
      如果在每個 Controller 組成 ViewModel 勢必會有一堆重複的 Code ,所以拉就到 Service 去組 ViewModel。
      還有我看到一篇文章似乎也是在 Service 層就將 DTO 物件組好如下:
      https://buildplease.com/pages/repositories-dto/
      所以想請教一下 Kevin 大組 DTO (或 ViewModel )的地方到底在哪邊比較合適呢?

      刪除
    5. 這邊是說展示層要做的事情不要因為共用或是想要少做一些事情而直接使用服務層或資料存取層的類別物件當作對外輸出資料的容器型別.

      你所提的那篇文章,作者是基於 DDD 的架構下去做專案的,而 Domain Model 是其專案的基礎,從上而下去貫穿,這必須要整個專案在一開始的規劃就要去做整體的考量.

      我會比較堅持 ViewModel 是在展示層裡,這是因為同一個 Solution 裡的展示層專案有可能會有多個,例如有 MVC 或 WebApi 等等,可能是不同產品,但是都是參考使用同樣的 Service 和 Repository,但每個產品所輸出與顯示的資料有所差異,這時候如果所有的展示層專案都是使用 Service 的輸出型別作為 ViewModel 時,就會發生有A產品出現B產品所要顯示的資料,雖然可以另外去做調整,但互相影響的情況將會頻繁出現,這就造成維護上的困難,所以各個網站或服務的輸出 ViewModel 不適合在服務層裡去做.

      另外就是服務層與資料存取層的設計,有很多人的服務層與資料層都是會先入為主的去依據某些商業邏輯或條件去設計和實作,這就已經先一步去侷限和限縮了服務層與資料存取層的定義和功能,例如會員資料,有些人取得會員資料會直接就去組合各種條件或是加上很多邏輯的 SQL Command 或 LINQ Expression,所取得的資料就已經是有被調整過的,而且是為了某些特定操作,因為已經是被設計過的,所輸出的資料就自然是最後要丟到 client 的內容,這時候就會覺得還要切 service 然後轉 DTO,最後還要給展示層去 mapping 到 ViewModel 這一串步驟就會覺得煩,所以就乾脆去簡化過程,直接撈出資料的型別就當作 viewmodel 給丟出去....

      至於你所提到自己的專案,我是不清楚你有沒有使用過 automapper 之類的別對映轉換套件,其實對映轉換的設定成本並不大,所以對我而言,遇到你的情況,我還是會堅持去切開來分別做,或者是去將這些有可能共用資料的部分去做服務化,而不是用每個專案去重複複製或參考的方式去加入服務層的資源.

      就我而言,在我的工作環境裡,為了避免後續維護與互相雜亂參考或複製的情況出現,所以嚴格執行職責區分與分層操作的方式,因為時常出現某些功能的商業邏輯要修改,此時去調整服務層,當服務層完成後,再通過單元測試,最後展示層再去做局部處理避且通過單元測試,就可以確保修改完成是正確的,另外也可以在日後出現問題時第一時間就可以釐清是哪一層的問題.

      人云亦云,各家門派都有不同,最重要的還是要看專案與團隊開發規範而訂,不是誰說了就算,至少我所堅持的就是那一層該要去做的就在該層去處理,不要去為了方便而走捷徑.

      刪除
    6. 作者已經移除這則留言。

      刪除
    7. 其實我有用過 Automapper ,所以類別對映轉換不是我想拉到 Service 去組 DTO(or ViewModel )的原因,
      而是因為想把 linq 篩選資料的邏輯拉到 Service 層給多個展示層的專案調用,這樣才不會在多個不同專案的 controller 出現同樣的篩選資料邏輯

      刪除
    8. 看到這邊讓我感到困惑... 留言已經完全偏離文章主題
      我沒有看過你的專案,也不明白你的專案架構與做法和需求,我也不清楚你們專案的分層是怎麼去區分與定義,
      就如同我前面所說,專案的架構與實作都各有不同,會依據實際情況去做調整,
      但保持一個原則,那就是職責區分清楚,不貪心、不做多也不多做
      以上

      刪除
    9. 筆記:職責區分清楚,不貪心、不做多也不多做

      刪除
    10. 哈哈...
      本來問題是 ViewModel 放在非 Web api 專案下不會產生 help page 要用的 XmlDocument,
      結果就這樣扯到 viewmodel 要放哪裡的責任分派問題啦~
      感謝 K 大的指教,受益良多

      刪除

提醒

千萬不要使用 Google Talk (Hangouts) 或 Facebook 及時通訊與我聯繫、提問,因為會掉訊息甚至我是過了好幾天之後才發現到你曾經傳給我訊息過,請多多使用「詢問與建議」(在左邊,就在左邊),另外比較深入的問題討論,或是有牽涉到你實作程式碼的內容,不適合在留言板裡留言討論,請務必使用「詢問與建議」功能(可以夾帶檔案),謝謝。