從一次解決Nancy引數繫結“bug”開始釋出自己的第一個nuget包(上篇)

搖曳de風箏發表於2021-12-12

起因

      最近,同事跟我說,他們負責的一個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

從一次解決Nancy引數繫結“bug”開始釋出自己的第一個nuget包(上篇)

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

從一次解決Nancy引數繫結“bug”開始釋出自己的第一個nuget包(上篇)

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

從一次解決Nancy引數繫結“bug”開始釋出自己的第一個nuget包(上篇)

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

從一次解決Nancy引數繫結“bug”開始釋出自己的第一個nuget包(上篇)

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

從一次解決Nancy引數繫結“bug”開始釋出自己的第一個nuget包(上篇)

接下來通過多次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,一個方法呼叫另外一個方法,一個類引用另外一個類,只能一個一個嘗試,把需要的程式碼拿過來,最終的結果就是下面的樣子了。

從一次解決Nancy引數繫結“bug”開始釋出自己的第一個nuget包(上篇)

測試方法,直接寫在了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”開始釋出自己的第一個nuget包(上篇)

很明顯,我們復現了這個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部分置空即可。接下來,再次執行程式,我們會發現問題已經解決了。

從一次解決Nancy引數繫結“bug”開始釋出自己的第一個nuget包(上篇)

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

從一次解決Nancy引數繫結“bug”開始釋出自己的第一個nuget包(上篇)

我們會發現當引數部分進行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官方已經停止維護,不再更新了。

從一次解決Nancy引數繫結“bug”開始釋出自己的第一個nuget包(上篇)

 

從一次解決Nancy引數繫結“bug”開始釋出自己的第一個nuget包(上篇)

我們也沒辦法再提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包的釋出過程。本打算一次寫完的,無奈時間不早了,該睡覺覺了,剩下的內容留著下篇再寫吧。

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

相關文章