Hallo Habr!
In diesem Artikel möchten wir unsere Erfahrungen bei der Optimierung der Leistung und der Erkundung der Funktionen von AspNetCore.Mvc teilen.

Hintergrund
Vor einigen Jahren haben wir bei einem unserer geladenen Dienste einen erheblichen Verbrauch an CPU-Ressourcen festgestellt. Es sah seltsam aus, da die Aufgabe des Dienstes darin bestand, die Nachricht tatsächlich in die Warteschlange zu stellen, nachdem zuvor einige Vorgänge wie Validierung, Datenaddition usw. ausgeführt worden waren.
Als Ergebnis der Profilerstellung stellten wir fest, dass die Deserialisierung den größten Teil der Prozessorzeit „verschlingt“. Wir haben einen Standard-Serializer herausgeschmissen und unseren eigenen auf Jil geschrieben, wodurch der Ressourcenverbrauch um ein Vielfaches gesunken ist. Alles hat so funktioniert, wie es sollte und wir haben es geschafft, es zu vergessen.
Das Problem
Wir verbessern ständig unseren Service in allen Bereichen, einschließlich Sicherheit, Leistung und Fehlertoleranz. Daher führt das "Sicherheitsteam" verschiedene Tests unserer Services durch. Und vor einiger Zeit "fliegt" eine Warnung über einen Fehler im Protokoll zu uns - irgendwie haben wir eine ungültige Nachricht weiter verpasst.
Bei einer detaillierten Analyse sah alles ziemlich seltsam aus. Es gibt ein Anforderungsmodell (hier werde ich ein vereinfachtes Codebeispiel geben):
public class RequestModel { public string Key { get; set; } FromBody] Required] public PostRequestModelBody Body { get; set; } } public class PostRequestModelBody { [Required] [MinLength(1)] public IEnumerable<long> ItemIds { get; set; } }
Es gibt einen Controller mit der Aktion Post, zum Beispiel:
[Route("api/[controller]")] public class HomeController : Controller { [HttpPost] public async Task<ActionResult> Post(RequestModel request) { if (this.ModelState.IsValid) { return this.Ok(); } return this.BadRequest(); } }
Alles scheint logisch. Wenn eine Anfrage aus der Body-Ansicht kommt
{"itemIds":["","","" … ] }
Die API gibt BadRequest zurück, und es gibt Tests dafür.
Trotzdem sehen wir im Log das Gegenteil. Wir haben die Nachricht aus den Protokollen genommen, an die API gesendet und den Status OK erhalten ... und ... einen neuen Fehler im Protokoll. Wir trauten unseren Augen nicht, machten einen Fehler und stellten sicher, dass ja, tatsächlich ModelState.IsValid == true. Gleichzeitig stellten sie eine ungewöhnlich lange Ausführungszeit für Abfragen von etwa 500 ms fest, während die übliche Antwortzeit selten 50 ms überschreitet und dies in der Produktion ist, die Tausende von Anfragen pro Sekunde bedient!
Der Unterschied zwischen dieser Anfrage und unseren Tests bestand nur darin, dass die Anfrage mehr als 600 leere Zeilen enthielt ...
Als nächstes wird viel Bukaf-Code kommen.
Grund
Sie begannen zu verstehen, was los war. Um den Fehler zu beseitigen, haben sie eine saubere Anwendung geschrieben (von der ich ein Beispiel gegeben habe), mit der wir die beschriebene Situation reproduziert haben. Insgesamt haben wir ein paar Manntage mit Recherchen, Tests und dem mentalen Debug-Code AspNetCore.Mvc verbracht, und es stellte sich heraus, dass das Problem in
JsonInputFormatter liegt .
JsonInputFormatter verwendet einen JsonSerializer, der beim Abrufen eines Streams zur Deserialisierung und zum Typ versucht, jede Eigenschaft zu serialisieren, wenn es sich um ein Array handelt - jedes Element dieses Arrays. Wenn während der Serialisierung ein Fehler auftritt, speichert JsonInputFormatter jeden Fehler und seinen Pfad und markiert ihn als verarbeitet, damit Sie den Versuch fortsetzen können, die Deserialisierung fortzusetzen.
Unten finden Sie den Code für die JsonInputFormatter-Fehlerbehandlungsroutine (verfügbar auf Github unter dem obigen Link):
void ErrorHandler(object sender, Newtonsoft.Json.Serialization.ErrorEventArgs eventArgs) { successful = false; // When ErrorContext.Path does not include ErrorContext.Member, add Member to form full path. var path = eventArgs.ErrorContext.Path; var member = eventArgs.ErrorContext.Member?.ToString(); var addMember = !string.IsNullOrEmpty(member); if (addMember) { // Path.Member case (path.Length < member.Length) needs no further checks. if (path.Length == member.Length) { // Add Member in Path.Memb case but not for Path.Path. addMember = !string.Equals(path, member, StringComparison.Ordinal); } else if (path.Length > member.Length) { // Finally, check whether Path already ends with Member. if (member[0] == '[') { addMember = !path.EndsWith(member, StringComparison.Ordinal); } else { addMember = !path.EndsWith("." + member, StringComparison.Ordinal); } } } if (addMember) { path = ModelNames.CreatePropertyModelName(path, member); } // Handle path combinations such as ""+"Property", "Parent"+"Property", or "Parent"+"[12]". var key = ModelNames.CreatePropertyModelName(context.ModelName, path); exception = eventArgs.ErrorContext.Error; var metadata = GetPathMetadata(context.Metadata, path); var modelStateException = WrapExceptionForModelState(exception); context.ModelState.TryAddModelError(key, modelStateException, metadata); _logger.JsonInputException(exception); // Error must always be marked as handled // Failure to do so can cause the exception to be rethrown at every recursive level and // overflow the stack for x64 CLR processes eventArgs.ErrorContext.Handled = true; }
Markierungsfehler als verarbeitet werden ganz am Ende des Prozessors gemacht
eventArgs.ErrorContext.Handled = true;
Somit ist eine Funktion implementiert, mit der alle Deserialisierungsfehler und Pfade zu ihnen ausgegeben werden können, auf welchen Feldern / Elementen sie sich befanden, na ja ... fast alle ...
Tatsache ist, dass JsonSerializer ein Limit von 200 Fehlern hat, wonach es abstürzt, während der gesamte Code abstürzt und ModelState ... gültig wird! ... Fehler ebenfalls verloren gehen.
Lösung
Ohne zu zögern haben wir unseren Json-Formatierer für Asp.Net Core mit dem Jil Deserializer implementiert. Da die Anzahl der Fehler im Körper für uns absolut nicht wichtig ist, ist nur die Tatsache ihres Vorhandenseins wichtig (und es ist im Allgemeinen schwierig, sich eine Situation vorzustellen, in der es wirklich nützlich wäre), erwies sich der Serializer-Code als recht einfach.
Ich werde den Hauptcode des benutzerdefinierten JilJsonInputFormatter geben:
using (var reader = context.ReaderFactory(request.Body, encoding)) { try { var result = JSON.Deserialize( reader: reader, type: context.ModelType, options: this.jilOptions); if (result == null && !context.TreatEmptyInputAsDefaultValue) { return await InputFormatterResult.NoValueAsync(); } else { return await InputFormatterResult.SuccessAsync(result); } } catch { // - } return await InputFormatterResult.FailureAsync(); }
Achtung! Jil unterscheidet zwischen Groß- und Kleinschreibung, dh dem Inhalt des Körpers
{"ItemIds":["","","" … ] }
und
{"itemIds":["","","" … ] }
nicht das gleiche. Wenn im ersten Fall camelCase verwendet wird, ist die ItemIds-Eigenschaft nach der Deserialisierung null.
Da dies jedoch unsere API ist, verwenden und steuern wir sie. Für uns ist dies nicht kritisch. Das Problem kann auftreten, wenn es sich um eine öffentliche API handelt und diese bereits von jemandem aufgerufen wird, wobei der Parametername nicht in camelCase übergeben wird.
Ergebnis
Das Ergebnis hat sogar unsere Erwartungen übertroffen. Die API hat BadRequest erwartungsgemäß auf die angeforderte Anforderung zurückgesetzt und dies sehr schnell getan. Unten finden Sie Screenshots einiger unserer Diagramme, die Änderungen der Antwortzeit und der CPU vor und nach der Bereitstellung deutlich zeigen.
Vorlaufzeit anfordern:

Gegen 16:00 Uhr gab es einen Einsatz. Vor der Bereitstellung betrug die Ausführungszeit von p99 30-57 ms, nach der Bereitstellung 9-15 ms. (Sie können nicht auf die wiederholten Spitzen von 18:00 achten - dies war ein weiterer Einsatz)
So hat sich das CPU-Diagramm geändert:

Aus diesem Grund haben wir Github ein
Problem gebracht. Zum Zeitpunkt des Schreibens wurde es als Fehler mit Meilenstein 3.0.0-Vorschau3 gekennzeichnet.
Abschließend
Bis das Problem behoben ist, halten wir es für besser, die Verwendung der Standarddeserialisierung aufzugeben, insbesondere wenn Sie über eine öffentliche API verfügen. Wenn ein Angreifer dieses Problem kennt, kann er Ihren Dienst leicht einschalten und eine Reihe ähnlicher ungültiger Anforderungen in ihn werfen. Je größer das fehlerhafte Array, desto mehr Body, desto länger findet die Verarbeitung in JsonInputFormatter statt.
Artyom Astashkin, Leiter des Entwicklungsteams