從一次解決Nancy參數綁定“bug”開始發布自己的第一個nuget包(上篇)


起因

      最近,同事跟我說,他們負責的一個Api程序出現了一些很奇怪的事情。這個Api是為環保局做的一個揚塵質控大屏提供數據的,底層是基於Nancy做的。因為發現有些接口的數據出現異常,他就去調試了一下,發現當前端傳遞的參數如果是空,后端反序列化的時候會出現參數值和參數名是一樣的情況,這就會導致查詢的數據錯誤。沒有找到原因之前,只能通過nameof來判斷做處理。具體情況,見下圖。

 

      問題就是這么個問題,其實就是因為傳遞的參數不合規則導致的。正常情況下,參數應該是參數名1=參數值1&參數名2=參數值2,但是這里傳遞的空參數缺少了=,導致后端識別解析出了問題,只要按照正常的參數名1=參數值1&參數名2=參數值2即可解決問題,所以這里的bug是加了雙引號,並不能完全是Nancy的鍋。由於前端是基於公共的saas軟件服務開發的,參數格式我們也無法修改的,於是就想在后端做一些處理來解決這個問題。

查源碼

      我們在Module里通過下面的代碼來定義一個Api,使用Bind<T>來反序列化請求的參數模型。 

/// <summary>
/// GET請求示例
/// </summary>
/// <param name="_"></param>
/// <returns></returns>
public Response GetSamp(dynamic _)
{
    var req = this.Bind<SampleInDto>();
    return Response.AsJson(req);
}

F12找到Bind<T>定義的位置,見下圖

        //
        // 摘要:
        //     Bind the incoming request to a model
        //
        // 參數:
        //   module:
        //     Current module
        //
        // 類型參數:
        //   TModel:
        //     Model type
        //
        // 返回結果:
        //     Bound model instance
        public static TModel Bind<TModel>(this INancyModule module)
        {
            return module.Bind();
        }

 

這個方法是接口所定義的一個方法INancyModule,再次F12進入,可以發現INancyModule有一個上下文對象NancyContext

這個就是整個HTTP請求的上下文對象,進入NancyContext可以發現他包含HTTP請求中的的RequestResponse對象

我們再進入Request對象,可以發現它包含一些常見對象,比如form,cookies,header,Query,method等等。

我們可以發現,FormQuery的類型是dynamic動態類型,其中Form定義的是一個DynamicDictionary類型的動態類型,從字面理解也很容易想到,這兩個對象保存的就是我們HTTP請求中通過地址和表單提交的參數鍵值對字典。因為我們的HTTP請求都是通過GET方式發起的地址傳參,所以暫時不去理會Form,先去找到Query賦值的地方。最好的方式就是通過源碼來查看,那就先去https://github.com/NancyFx/Nancy下載Nancy的源碼吧。源碼下載完畢,先去除一些無用的項目集,之后便是Nancy的源碼吧。還是很意外的,沒想到一個打着輕量級名號的WebApi框架,源碼居然這么龐大。

源碼在手,我們先找到Query定義的位置,通過查找引用,我們可以發現是通過一個AsQueryDictionary()的方法進行了賦值的。

接下來通過多次F12可以發現數據來自System.UriQuery對象,這個Query的類型是string說白了,這個就是url中從?開始的部分。比如?name=張三&age=20,相信大家都很容易理解這些吧。

        /// <summary>
        /// Initializes a new instance of the <see cref="Url" /> class, with
        /// the provided <paramref name="url"/>.
        /// </summary>
        /// <param name="url">A <see cref="string" /> containing a URL.</param>
        public Url(string url)
        {
            var uri = new Uri(url);
            this.HostName = uri.Host;
            this.Path = uri.LocalPath;
            this.Port = uri.Port;
            this.Query = uri.Query;
            this.Scheme = uri.Scheme;
        }

下面,重點就是這個AsQueryDictionary()的方法了,它的作用就是對字符串部分的參數進行重新組裝,放到動態字典類型的Query中,此Query非彼Query。我猜測,出問題的地方就在這個AsQueryDictionary()里面,為了驗證猜想,我決定把相關的代碼扒下來,通過一個簡單的控制台程序來驗證。這個過程比較DT,一個方法調用另外一個方法,一個類引用另外一個類,只能一個一個嘗試,把需要的代碼拿過來,最終的結果就是下面的樣子了。

測試方法,直接寫在了Main方法中,如下所示。

class Program
    {
        static void Main(string[] args)
        {
            string format = "http://localhost:5050/hello?{0}";
            var context = new NancyContext();
            string[] arrNormal = new string[] {
                                                        string.Format(format, "name=張三&age=20"),
                                                        string.Format(format, "name=張三&age="),
                                                        string.Format(format, "name&age"),
                                                        string.Format(format, "name=&age")
                                                    };
            Console.WriteLine(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>明文參數>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
            foreach (string _url in arrNormal)
            {
                Console.WriteLine($"=============={_url}==============");
                var url = new Url(_url);
                context.Request = new Request(url);
                var dic = context.Request.Query as DynamicDictionary;
                foreach (string key in dic.Keys)
                {
                    Console.WriteLine($"{key}={dic[key]}");
                }
            }
            Console.ReadKey();
        }
    }

我准備了幾種傳參的方式,把最后得到的Query打印出來,如下圖。

很明顯,我們復現了這個Nancy的小“bug”。現在我們已經知道是因為AsQueryDictionary()的緣故了,接下來就是斷點調試一下,看看到底是怎么回事。

最核心的兩個方法是下面的這兩個方法,代碼很簡單,相信大家都能理解,我簡單說一下,第一個方法是根據參數的連接符&分組,然后遍歷這個數組,將每個組的內容再根據鍵值對的連接符=來分組,分別取出參數名和參數值,放入到字典中。

internal static void ParseQueryString(string query, Encoding encoding, NameValueCollection result)
        {
            if (query.Length == 0)
                return;

            var decoded = HtmlDecode(query);

            var segments = decoded.Split(new[] { '&' }, StringSplitOptions.None);

            foreach (var segment in segments)
            {
                var keyValuePair = ParseQueryStringSegment(segment, encoding);
                if (!Equals(keyValuePair, default(KeyValuePair<string, string>)))
                    result.Add(keyValuePair.Key, keyValuePair.Value);
            }
        }

        private static KeyValuePair<string, string> ParseQueryStringSegment(string segment, Encoding encoding)
        {
            if (String.IsNullOrWhiteSpace(segment))
                return default(KeyValuePair<string, string>);

            var indexOfEquals = segment.IndexOf('=');
            if (indexOfEquals == -1)
            {
                var decoded = UrlDecode(segment, encoding);
                return new KeyValuePair<string, string>(decoded, decoded);
            }

            var key = UrlDecode(segment.Substring(0, indexOfEquals), encoding);
            var length = (segment.Length - indexOfEquals) - 1;
            var value = UrlDecode(segment.Substring(indexOfEquals + 1, length), encoding);
            return new KeyValuePair<string, string>(key, value);
        }

本來,這樣操作是正常操作,沒啥毛病。但是,我們注意看這一段代碼。

var indexOfEquals = segment.IndexOf('=');
if (indexOfEquals == -1)
{
    var decoded = UrlDecode(segment, encoding);
    return new KeyValuePair<string, string>(decoded, decoded);
}

它發現參數分組中沒有=連接符,會先進行一波url參數解碼,然后將解碼的內容既當key又作value放入了字典中,這也太騷了,我無法理解為什么會這么寫,可能是我的段位太低了,始終無法理解其含義。但是這就是導致這個問題的根本原因,我們只需要對這一段代碼進行一下稍微的改造即可。

var indexOfEquals = segment.IndexOf('=');
if (indexOfEquals == -1)
{
     segment = UrlDecode(segment, encoding);
     indexOfEquals = segment.IndexOf('=');
      if (indexOfEquals == -1)
      {
           return new KeyValuePair<string, string>(segment, "");
       }
}

我們在發現參數分組中沒有正常的key=value組合時,將value部分置空即可。接下來,再次運行程序,我們會發現問題已經解決了。

問題雖然解決,但是我們發現源碼中有很多對參數進行解碼的操作。於是,我就在想如果參數編碼之后,能否正常解析呢?於是,我就准備了幾組不同格式的參數進行了驗證。

我們會發現當參數部分進行url編碼之后,已經是無法正常解析了。於是,再次對源碼進行調試分析。很快,就定位到下面這一段代碼。

var decoded = HtmlDecode(query);
var segments = decoded.Split(new[] { '&' }, StringSplitOptions.None);

斷點之后,發現當經過url編碼的參數字符串query到這里,無法查找到參數連接符&,導致所有的參數都變成了同一個參數。解決辦法也很簡單,就是不存在&連接符的時候,再次進行解碼即可。

var decoded = HtmlDecode(query);
if (decoded.IndexOf('&') == -1)
{
    decoded = UrlDecode(decoded, encoding);
}
var segments = decoded.Split(new[] { '&' }, StringSplitOptions.None);

 

至此,問題得到解決。但是新的問題產生了,怎么解決這個問題

NNancy官方已經停止維護,不再更新了。

 

我們也沒辦法再提issue了。

解決辦法

      很明顯,這兩個私有方法,我們是無法重寫的,那我們怎么辦呢?Nancy和ASP.NET MVC框架一樣是有過濾器的,我們可以在攔截器中對上下文中的Query進行修改。

首先,我們來新建一個擴展方法類,拿來主義,直接把源碼中HttpUtility.cs文件拿過來,新建兩個修復方法:ParseQueryStringFixParseQueryStringSegmentFix,完整代碼如下。

using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Text;
using HttpUtility = Nancy.Helpers.HttpUtility;

namespace Nancy.FixQueryDictionary
{
    /// <summary>
    /// Nancy Http請求參數字典解析錯誤修復擴展方法
    /// </summary>
    public static class NancyFixQueryDictionaryExtensions
    {
        /// <summary>
        /// 修復Http請求參數字典解析錯誤
        /// </summary>
        /// <param name="ctx">NancyContext對象</param>
        /// <returns>NancyContext對象</returns>
        public static NancyContext FixQueryDictionary(this NancyContext ctx)
        {
            if (ctx == null)
            {
                return ctx;
            }
            ctx.Request.Query = ctx.Request.Url.Query.AsQueryDictionary();
            return ctx;
        }
        /// <summary>
        /// 
        /// </summary>
        /// <param name="queryString"></param>
        /// <returns></returns>
        public static DynamicDictionary AsQueryDictionary(this string queryString)
        {
            var coll = ParseQueryString(queryString);
            var ret = new DynamicDictionary();
            var found = 0;
            foreach (var key in coll.AllKeys.Where(key => key != null))
            {
                ret[key] = coll[key];
                found++;
                if (found >= StaticConfiguration.RequestQueryFormMultipartLimit)
                {
                    break;
                }
            }
            return ret;
        }
        /// <summary>
        /// 
        /// </summary>
        /// <param name="query"></param>
        /// <returns></returns>
        public static NameValueCollection ParseQueryString(string query)
        {
            return ParseQueryString(query, Encoding.UTF8);
        }
        /// <summary>
        /// 
        /// </summary>
        /// <param name="query"></param>
        /// <param name="caseSensitive"></param>
        /// <returns></returns>
        public static NameValueCollection ParseQueryString(string query, bool caseSensitive)
        {
            return ParseQueryString(query, Encoding.UTF8, caseSensitive);
        }
        /// <summary>
        /// 
        /// </summary>
        /// <param name="query"></param>
        /// <param name="encoding"></param>
        /// <returns></returns>
        public static NameValueCollection ParseQueryString(string query, Encoding encoding)
        {
            return ParseQueryString(query, encoding, StaticConfiguration.CaseSensitive);
        }
        /// <summary>
        /// 
        /// </summary>
        /// <param name="query"></param>
        /// <param name="encoding"></param>
        /// <param name="caseSensitive"></param>
        /// <returns></returns>
        /// <exception cref="ArgumentNullException"></exception>
        public static NameValueCollection ParseQueryString(string query, Encoding encoding, bool caseSensitive)
        {
            if (query == null)
                throw new ArgumentNullException("query");
            if (encoding == null)
                throw new ArgumentNullException("encoding");
            if (query.Length == 0 || (query.Length == 1 && query[0] == '?'))
                return new NameValueCollection(StringComparer.Ordinal);
            if (query[0] == '?')
                query = query.Substring(1);

            NameValueCollection result = new NameValueCollection(StringComparer.Ordinal);
            ParseQueryStringFix(query, encoding, result);
            return result;
        }

        #region 原方法
        internal static void ParseQueryString(string query, Encoding encoding, NameValueCollection result)
        {
            if (query.Length == 0)
                return;

            var decoded = HttpUtility.HtmlDecode(query);

            var segments = decoded.Split(new[] { '&' }, StringSplitOptions.None);

            foreach (var segment in segments)
            {
                var keyValuePair = ParseQueryStringSegment(segment, encoding);
                if (!Equals(keyValuePair, default(KeyValuePair<string, string>)))
                    result.Add(keyValuePair.Key, keyValuePair.Value);
            }
        }

        private static KeyValuePair<string, string> ParseQueryStringSegment(string segment, Encoding encoding)
        {
            if (String.IsNullOrWhiteSpace(segment))
                return default(KeyValuePair<string, string>);

            var indexOfEquals = segment.IndexOf('=');
            if (indexOfEquals == -1)
            {
                var decoded = HttpUtility.UrlDecode(segment, encoding);
                return new KeyValuePair<string, string>(decoded, decoded);
            }

            var key = HttpUtility.UrlDecode(segment.Substring(0, indexOfEquals), encoding);
            var length = (segment.Length - indexOfEquals) - 1;
            var value = HttpUtility.UrlDecode(segment.Substring(indexOfEquals + 1, length), encoding);
            return new KeyValuePair<string, string>(key, value);
        }
        #endregion

        #region 修復方法
        internal static void ParseQueryStringFix(string query, Encoding encoding, NameValueCollection result)
        {
            if (query.Length == 0)
                return;

            var decoded = HttpUtility.HtmlDecode(query);
            if (decoded.IndexOf('&') == -1)
            {
                decoded = HttpUtility.UrlDecode(decoded, encoding);
            }
            var segments = decoded.Split(new[] { '&' }, StringSplitOptions.None);

            foreach (var segment in segments)
            {
                var keyValuePair = ParseQueryStringSegmentFix(segment, encoding);
                if (!Equals(keyValuePair, default(KeyValuePair<string, string>)))
                    result.Add(keyValuePair.Key, keyValuePair.Value);
            }
        }

        private static KeyValuePair<string, string> ParseQueryStringSegmentFix(string segment, Encoding encoding)
        {
            if (String.IsNullOrWhiteSpace(segment))
                return default(KeyValuePair<string, string>);

            var indexOfEquals = segment.IndexOf('=');
            if (indexOfEquals == -1)
            {
                segment = HttpUtility.UrlDecode(segment, encoding);
                indexOfEquals = segment.IndexOf('=');
                if (indexOfEquals == -1)
                {
                    return new KeyValuePair<string, string>(segment, "");
                }
            }
            var key = HttpUtility.UrlDecode(segment.Substring(0, indexOfEquals), encoding);
            var length = (segment.Length - indexOfEquals) - 1;
            var value = HttpUtility.UrlDecode(segment.Substring(indexOfEquals + 1, length), encoding);
            var res = new KeyValuePair<string, string>(key, value);
            return res;
        }
        #endregion
    }
}

接下來,我們在攔截器中進行參數攔截處理。

/// <summary>
/// 前置攔截器
/// </summary>
/// <param name="ctx">NancyContext上下文對象</param>
/// <returns></returns>
private Response BeforeRequest(NancyContext ctx)
{
   ctx.FixQueryDictionary();
   //TODO:

   return ctx.Response;
}

總結

      至此,我們的問題問題都得到了解決。本來到此應該結束了,但是心血來潮,想要把這個代碼發布成nuget包,完成人生第一個nuget包的發布,想想還是挺激動的,經過一番百度操作,大概了解了nuget包的發布過程。本打算一次寫完的,無奈時間不早了,該睡覺覺了,剩下的內容留着下篇再寫吧。

如果各位大佬對此問題有什么更好的高見,歡迎留言!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM