Image

事發緣由

在 .net Framework 4.6.2 MVC 的 ApiController 中,某個查詢資料列表的方法除了提供查詢條件的參數以外,還有提供選擇性的分頁參數。也就是像這樣子:

[HttpGet]
public IEnumerable<Boo> GetBoos(
    [FromUri] SearchBooParameter parameter,
    [FromUri] PagingParameter paging = null)
{
    // 呼叫 Service 查資料...
}

由於需要調整該功能的預設排序,改為由大到小,又不想背負更改大量共用的 PagingParameter 去影響到其他使用到的地方,決定在 Controller 這裡簡單用預測值加上判斷處理一下就好

相信著「若使用者沒有傳遞 paging 相關的參數,應該就會是給定的預設值 null 吧!」的我,用了 if (paging is null) 進行判斷:若是 null 的情況就將其中用來標示排序方向的成員 isDesc 設定為 true,開開心心交差。

[HttpGet]
public IEnumerable<Boo> GetBoos(
    [FromUri] SearchBooParameter parameter,
    [FromUri] PagingParameter paging = null)
{
    if (paging is null)
    {
        paging = new PagingParameter();
        paging.isDesc = true; // 預設由大到小
    }
    // 呼叫 Service 查資料...
}

但實際使用之後發現:即使只有傳入查詢條件參數、未傳遞 paging 時,資料仍然由小到大顯示,且 paging.isDesc 竟然是 false,並未被更改到。也就是說,即使未傳遞 paging,它也並不是 null!

實測之後發現:若在呼叫該 API 的時候,給定一個完全無關的參數,例如 ?a=1,則 paging 還是會被建立一個實體出來,並無視 = null 這個預設值。因此就導致了非預期(=跟我想的不一樣啊!)的行為。

這邊直接先講結論:如果有傳遞 QueryString 的任何參數時,不管這些參數跟指定的類別有沒有關係,放在 [FromUri] 的複雜型別都會先建立出實體,再嘗試和 QueryString 的內容進行比對與設值

設定在 [FromUri] 的複雜型別身上的預設值,像是 [FromUri] PagingParameter paging = null 只有完全沒給任何 QueryString 的時候才會吃到。(不過因為預設值只能是常數的關係,基本上就是指 defualt 的 null)

因此如果遇到要給定預設值的場合,還是得乖乖地針對型別中的成員做設定比較保險,例如 bool isDesc { get; set; } = true。另外,因為完全沒給 QueryString 的時候還是會是 null,故該有的參數檢查仍然不能漏了。

測試案例

.net Framework 4.7.2 Web Api - FromUri

[RoutePrefix("api")]
public class ValuesController : ApiController
{
    [HttpGet, Route("Test")]
    public string Test([FromUri] SimplyParameter parameter = null)
    {
        return parameter?.IsSuccess.ToString() ?? "It's NULL!"; // False
    }
    
    public class SimplyParameter
    {
        public bool IsSuccess { get; set; }
        public string Message { get; set; }
    }
}

/api/test => It’s NULL!

/api/test?a=2 => False

若改為必須參數,而不是選擇性參數呢:

[HttpGet, Route("Test")]
public string Test([FromUri] SimplyParameter parameter)
{
    return parameter?.IsSuccess.ToString() ?? "It's NULL!"; // False
}

/api/test => It’s NULL!

/api/test?a=2 => False

看起來最大的差異點在於有沒有給 QueryString

現在讓我們對 Model 給定預設值,並加入建構式並觀察執行順序:

[RoutePrefix("api")]
public class ValuesController : ApiController
{
    [HttpGet, Route("Test")]
    public string Test([FromUri] SimplyParameter parameter = null)
    {
        return parameter?.IsSuccess.ToString() ?? "It's NULL!"; // False
    }

    public class SimplyParameter
    {
        public SimplyParameter()
        {
            Console.WriteLine("I'm Creating!");
        }
        
        public bool IsSuccess { get; set; } = true;
        public string Message { get; set; } = "It's me!";
    }
}

/api/test?a=2 => True

並且在 “It’s me!” 下中斷點,可以觀察到在進入 Test 前,的確建立了 SimplyParameter 並給予預設值

/api/test?IsSuccess=true

中斷點的執行順序為:

  1. 建立 SimplyParameter
  2. 執行 public bool IsSuccess = true; 給予初始值
  3. 執行 public string Message = "It's me!" 給予初始值
  4. 呼叫 SimplyParameter() 建構式,觸發 "I'm Creating!"
  5. 呼叫 IsSuccessset,將 QueryString 傳遞的值塞進去
  6. 對於 QueryString 未提供的 Message,則未呼叫其 set,保持預設值
  7. 回到 string Test() 方法,SimplyParameter 已建立,無視 parameter = null

看起來當有傳遞 QueryString 的時候,就會先建立需要的 Model 再逐一嘗試設值。

接著讓我們試試看,當 [FromUri] 的複雜型別不只一個的時候會怎麼運作呢:

[RoutePrefix("api")]
public class ValuesController : ApiController
{
    [HttpGet, Route("Test")]
    public string Test(
        [FromUri] SimplyParameter parameter = null,
        [FromUri] AnotherSimplyParameter parameterAnother = null)
    {
        return parameter?.IsSuccess.ToString() ?? "It's NULL!"; // False
    }

    public class SimplyParameter
    {
        public SimplyParameter()
        {
            Console.WriteLine("I'm Creating!");
        }

        public bool IsSuccess { get; set; } = true;
        public string Message { get; set; } = "It's me!";
    }

    public class AnotherSimplyParameter
    {
        public AnotherSimplyParameter()
        {
            Console.WriteLine("I, Another me, are Creating!");
        }

        public int Idx { get; set; } = 999;
        public string Name { get; set; } = "WOW";
    }
}

/api/test?a=2

SimplyParameter 和 AnotherSimplyParameter 會依照 Test() 中傳入的順序,各自經歷一次上述的流程,故兩者都不會是 Null

/api/test?IsSuccess=true

即使傳入的參數只有其中一個 Model 具有符合的欄位,但由於上述的順序是「先建立,再比對」,故仍然會按上述流程分別建立兩者,仍然不會是 Null

/api/test?parameter.IsSuccess=true

即使已經具名指定了 SimplyParameter,但動作和 IsSuccess=true 仍然一樣

最後,我們加入一個簡單型別試試看,也就是改為如下:

public string Test(
    [FromUri] SimplyParameter parameter = null,
    [FromUri] AnotherSimplyParameter parameterAnother = null,
    [FromUri] string hello = "world!")

/api/test => hello = “world!”;

/api/test?a=2 => hello = “world!”;

簡單型別的預設值在運作上非常直覺,沒有什麼太大的問題。

[HttpGet, Route("Test")]
public string Test(
    [FromUri] string hello,
    [FromUri] SimplyParameter parameter = null,
    [FromUri] AnotherSimplyParameter parameterAnother = null)

/api/test?hello=A

即使我們已經給了作為必要參數的簡單型別值,但由於同樣是從 Uri 來的,並不會在把值丟給簡單型別後就停止,兩個可選的複雜型別仍然會被建立

小結

  • 在指定複雜型別為 [FromUri] 的場合,有以下注意事項
    • 有傳遞 QueryString 的參數時,放在 [FromUri] 的複雜型別會先建立出實體,再嘗試和 QueryString 的內容進行比對與設值
      • 也因為會先建立實體,故選擇性參數的預設值,例如 parameter = null 或是 parameter = default 並沒有效果,而是以該類型中各成員的預設值為主
      • 因此如果遇到要針對各參數給定初始值的場合,請不要直接設定在 [FromUri] 的複雜型別身上再偷雞手動給值,而是乖乖地針對型別中的成員做設定
    • parameter = default 當且僅當沒有傳遞任何 QueryString 的時候才有效(不過原本都沒給就會是 Default ,也就是 null,所以有沒有效我肉眼也分不太出來)
  • 然後關於一開始的問題,最後認命地請使用端傳參數解決了,耶嘿

同場加映

.net Core 3.1 Web Api - FromQuery

[ApiController]
[Route("[controller]")]
public class TestController : ControllerBase
{
    [HttpGet]
    public string Get([FromQuery] SimplyParameter parameter = null)
    {
        return parameter?.IsSuccess.ToString() ?? "It's NULL!"; // False
    }
}

public class SimplyParameter
{
    public bool IsSuccess { get; set; }
    public string Message { get; set; }
}

/test => False

不愧是 Core,直接就建實體了,畢竟都說是 FromQuery 了嘛。既然都直接說會從 Query 來了,就沒在跟你五四三看有沒有 QueryString 的啦 XD

參考資料