用了這麼多年,這幾天才發現SteamReader的一項行為。故事從jQuery.post內容給MVC接收說起…

我有一段MVC Action程式,會從Request.InputStream接收來自jQuery.ajax送來的內容,為求簡化起見,就拿舊文範例來示範:

@{
    ViewBag.Title = "Home Page";
}
<br />
<button id="btnPost">Post Content to Action</button>
@section scripts {
<script>
    //以application/json ContentType傳送JSON字串到Server端
    jQuery.postJson = function (url, data, callback, type) {
        if (jQuery.isFunction(data)) {
            type = type || callback;
            callback = data;
            data = undefined;
        }
 
        return jQuery.ajax({
            url: url,
            type: "POST",
            dataType: type,
            contentType: "application/json",
            data: typeof (data) == "string" ? data : JSON.stringify(data),
            success: callback
        });
    };
    $("#btnPost").click(function () {
        var players = [{
            Id: 1000, Name: "Darkthread",
            RegDate: new Date(Date.UTC(2000, 0, 1)),
            Score: 32767
        }, {
            Id: 1024, Name: "Jeffrey",
            RegDate: new Date(Date.UTC(2000, 0, 1)),
            Score: 9999
        }];
 
        $.postJson("@Url.Content("~/home/send")",
            players, function (res) {
            alert(res);
        });
    });
</script>
}

MVC Action的邏輯很簡單,用StreamReader.ReadToEnd()讀取Request.InputStream內容,將讀到的字串回傳給前端驗證:

public ActionResult Send()
{
    using (var sr = 
        new StreamReader(Request.InputStream))
    {
        return Content("POST Body=" + sr.ReadToEnd());
    }
}

輕鬆搞定!

後來,有個新需求-每次呼叫時需側錄Request內容存證,方便日後除錯或做為呈堂證供。

這也難不倒我,寫個IActionFilter就可以搞定:(這裡以Debug.Write示意,實際應用會寫Log)

public class LogPostBodyAttribute : FilterAttribute, IActionFilter
{
    public void OnActionExecuted(ActionExecutedContext filterContext)
    {
    }
 
    public void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var req = filterContext.HttpContext.Request;
        if (req.HttpMethod == "POST")
        {
            var inp = req.InputStream;
            using (var sr = new StreamReader(inp))
            {
                var postBody = sr.ReadToEnd();
                //將POST內容寫入Log檔,此處以Debug.WriteLine示意
                Debug.WriteLine("SrcIp: " + req.UserHostAddress);
                Debug.WriteLine("Content: " + postBody);
            }
        }
    }
}

寫好IActionFilter,在Action加註[LogPostBody]即可生效:

[LogPostBody()]
public ActionResult Send()
{
    using (var sr = 
        new StreamReader(Request.InputStream))
    {
        return Content("POST Body=" + sr.ReadToEnd());
    }
}

測試執行,果然在Output視窗觀察到側錄內容,成功!

開心不到1分鐘,就發現Action被我弄壞了 弄壞了 弄壞了…

經過爬文研究,理解到一件長期被我忽略的StreamReader行為:

This method is called by the public Dispose method and the Finalize method. Dispose invokes the protected Dispose method with the disposing parameter set to true. Finalize invokes Dispose with disposing set to false.

When the disposing parameter is true, this method releases all resources held by any managed objects that the StreamReader object references. This method invokes the Dispose method of each referenced object. 
from StreamReader.Dispose(bool disposing)

This implementation of Close calls the Dispose method passing a true value.
from StreamReader.Close()

當我們呼叫StreamReader.Close()或StreamReader.Dispose()時,背後都會觸發Dispose(true),導致正在使用的基底Stream物件也被Dispose()。而我在FilterAction中用using (var sr = new StreamReader(InputStream)) { … },using範圍結束時將呼叫StreamReader.Dispose(),InputStream跟著被Dispose(),之後輪到Send Action讀取InputStream內容時,由於InputStream已被銷毁,因此讀不到內容。

查了MSDN文件,得知Finalize()及Dispose(false)可以結束StreamReader但不Dispose底層的Stream,但受限於protected宣告無法從外部呼叫,故必須移除using並避免呼叫Close()、Dispose(),而讀取完畢記得Seek(0, SeekOrigin.Begin)將位置歸零。

public void OnActionExecuting(ActionExecutingContext filterContext)
{
    var req = filterContext.HttpContext.Request;
    if (req.HttpMethod == "POST")
    {
        var inp = req.InputStream;
        var sr = new StreamReader(inp);
        var postBody = sr.ReadToEnd();
        //讀取完畢要將讀取位置還原到起始點
        inp.Seek(0, SeekOrigin.Begin);
        //將POST內容寫入Log檔,此處以Debug.WriteLine示意
        Debug.WriteLine("SrcIp: " + req.UserHostAddress);
        Debug.WriteLine("Content: " + postBody);
        //注意:不要呼叫sr.Close()或sr.Dispose(),
        //否則StreamReader會終結底層的Stream物件釋放資源
    }
}

看到StreamReader沒被using包起來,心裡總覺得怪怪的… 我猜微軟RD也有人跟我一樣龜毛,所以從.NET 4.5起,StreamReader多了一個建構式,接受bool leavingOpen參數:StreamReader 建構函式 (Stream, Encoding, Boolean, Int32, Boolean) (System.IO)

當 leavingOpen == true,StreamReader在Dispose()時就不會強制關閉使用的Stream,故程式可改為:

public void OnActionExecuting(ActionExecutingContext filterContext)
{
    var req = filterContext.HttpContext.Request;
    if (req.HttpMethod == "POST")
    {
        var inp = req.InputStream;
        //https://goo.gl/A6AKGC .NET 4.5新增leaveOpen參數
        //當leaveOpen=true,StreamReader結束時將不呼叫底層Stream.Dispose()
        using (var sr = new StreamReader(inp,
            Encoding.UTF8, true, 1024, true))
        {
            var postBody = sr.ReadToEnd();
            //讀取完畢要將讀取位置還原到起始點
            inp.Seek(0, SeekOrigin.Begin);
            //將POST內容寫入Log檔,此處以Debug.WriteLine示意
            Debug.WriteLine("SrcIp: " + req.UserHostAddress);
            Debug.WriteLine("Content: " + postBody);
        }
    }
}

嗯,看起來順眼多了,收工。


Comments

Be the first to post a comment

Post a comment