起因
最近,同事跟我說,他們負責的一個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請求中的的Request和Response物件
我們再進入Request物件,可以發現它包含一些常見物件,比如form,cookies,header,Query,method等等。
我們可以發現,Form和Query的型別是dynamic動態型別,其中Form定義的是一個DynamicDictionary型別的動態型別,從字面理解也很容易想到,這兩個物件儲存的就是我們HTTP請求中通過地址和表單提交的引數鍵值對字典。因為我們的HTTP請求都是通過GET方式發起的地址傳參,所以暫時不去理會Form,先去找到Query賦值的地方。最好的方式就是通過原始碼來檢視,那就先去https://github.com/NancyFx/Nancy下載Nancy的原始碼吧。原始碼下載完畢,先去除一些無用的專案集,之後便是Nancy的原始碼吧。還是很意外的,沒想到一個打著輕量級名號的WebApi框架,原始碼居然這麼龐大。
原始碼在手,我們先找到Query定義的位置,通過查詢引用,我們可以發現是通過一個AsQueryDictionary()的方法進行了賦值的。
接下來通過多次F12可以發現資料來自System.Uri的Query物件,這個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檔案拿過來,新建兩個修復方法:ParseQueryStringFix,ParseQueryStringSegmentFix,完整程式碼如下。
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包的釋出過程。本打算一次寫完的,無奈時間不早了,該睡覺覺了,剩下的內容留著下篇再寫吧。
如果各位大佬對此問題有什麼更好的高見,歡迎留言!