Menangani JSON yang rusak di .NET dengan System.Text.Json
Apa yang dilakukan ReadFromJsonAsync pada JSON rusak dan janggal, kasus mana yang tidak melempar, dan pembaca yang mengubah masing-masing menjadi error yang jelas.
Contoh terakhir dijalankan terhadap API pada
API bisa menjawab 200 OK dengan body yang bukan JSON valid: response yang dipotong proxy, body kosong, halaman error HTML, nilai dengan tipe yang salah. Cara kode .NET Anda bereaksi menentukan apakah pengguna melihat error yang jelas atau crash dengan JsonException yang terkubur di log. Untuk mengujinya, Anda butuh response yang sengaja dirusak. https://api.testingapis.com punya sekumpulan; semuanya untuk anggota yang sudah masuk, dan gratis. Panduan ini menunjukkan persis apa yang dilakukan System.Text.Json dan ReadFromJsonAsync pada masing-masing, kasus yang tidak melempar padahal seharusnya, pembaca yang aman, dan tes xUnit. Semuanya dijalankan di .NET 10.
$ curl -i https://api.testingapis.com/api/formats/malformed-json
HTTP/1.1 200 OK
content-length: 61
content-type: application/json; charset=utf-8
{"id": 1, "name": "Alice", "tags": ["a", "b",], "active": truAda dua kesalahan dalam 61 byte itu: koma berlebih setelah "b" dan body yang terpotong di tengah true. Statusnya tetap 200 dan content type-nya tetap menyebut JSON.
JSON rusak dan janggal sesuai permintaan
| Request | Yang kembali |
|---|---|
GET /api/formats/malformed-jsonmasuk untuk melihat | 200, application/json, JSON tidak valid dan terpotong |
GET /api/formats/emptymasuk untuk melihat | 200 dengan body kosong |
GET /api/formats/no-contentmasuk untuk melihat | 204 No Content |
GET /api/formats/wrong-content-typemasuk untuk melihat | JSON valid berlabel text/html |
GET /api/formats/bommasuk untuk melihat | JSON valid dengan byte order mark UTF-8 di depannya |
GET /api/edge-cases/duplicate-keysmasuk untuk melihat | JSON valid dengan key yang sama dua kali |
GET /api/edge-cases/numbersmasuk untuk melihat | Batas int64, angka di luar rentang aman JavaScript, bilangan bulat 30 digit, angka sebagai string |
GET /api/edge-cases/top-level/stringmasuk untuk melihat | String JSON di tempat yang seharusnya objek |
GET /api/edge-cases/mixed-arraymasuk untuk melihat | Satu array berisi angka, string, boolean, null, objek, dan array |
Apa yang dilakukan ReadFromJsonAsync pada masing-masing
Setiap response dibaca ke record sederhana. JSON yang tidak valid melempar JsonException yang Path-nya menunjuk ke masalah; tiga kasus tidak melempar sama sekali:
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };
async Task Read<T>(string path, JsonSerializerOptions? options = null)
{
try
{
using var response = await http.GetAsync(path);
var value = await response.Content.ReadFromJsonAsync<T>(options);
Console.WriteLine($"{path,-38} OK {JsonSerializer.Serialize(value)}");
}
catch (JsonException ex)
{
Console.WriteLine($"{path,-38} JsonException at {ex.Path}: {ex.Message[..ex.Message.IndexOf(". ")]}");
}
}
await Read<Person>("/api/formats/malformed-json");
await Read<Person>("/api/formats/empty");
await Read<Person>("/api/formats/no-content");
await Read<Person>("/api/formats/wrong-content-type");
await Read<Person>("/api/formats/bom");
await Read<Person>("/api/edge-cases/duplicate-keys");
await Read<Person>("/api/edge-cases/top-level/string");
await Read<List<int>>("/api/edge-cases/mixed-array");
record Person(int Id, string Name);/api/formats/malformed-json JsonException at $.tags: The JSON array contains a trailing comma at the end which is not supported in this mode
/api/formats/empty JsonException at $: The input does not contain any JSON tokens
/api/formats/no-content JsonException at $: The input does not contain any JSON tokens
/api/formats/wrong-content-type OK {"Id":1,"Name":"Alice Johnson"}
/api/formats/bom OK {"Id":1,"Name":"Alice Johnson"}
/api/edge-cases/duplicate-keys OK {"Id":2,"Name":"second value"}
/api/edge-cases/top-level/string JsonException at $: The JSON value could not be converted to Person
/api/edge-cases/mixed-array JsonException at $[1]: The JSON value could not be converted to System.Int32| Response | Hasil | Mengapa penting |
|---|---|---|
| JSON terpotong atau tidak valid | JsonException, dengan Path, LineNumber, dan BytePositionInLine | Tangkap dan laporkan panggilan mana yang rusak, atau ia muncul sebagai crash tanpa penjelasan |
| 200 kosong, atau 204 | JsonException: tidak ada token JSON | Periksa body kosong sebelum parsing bila API bisa mengirimnya |
| JSON berlabel text/html | Di-parse tanpa keluhan | ReadFromJsonAsync tidak memeriksa Content-Type. Halaman error HTML sungguhan baru gagal kemudian, sebagai JSON tidak valid |
| Byte order mark | Di-parse | Ditangani untuk Anda |
| Key yang sama dua kali | Di-parse: nilai terakhir menang, diam-diam | id menjadi 2 dan name "second value". Jadikan error dengan AllowDuplicateProperties = false (.NET 10) |
| Bentuk salah (string, array campuran) | JsonException pada nilai buruk pertama | Path menyebut lokasinya: $ untuk root, $[1] untuk elemen kedua |
Angka
C# menangani bilangan bulat besar lebih baik daripada JavaScript: long menyimpan 9007199254740993 persis, sementara JavaScript membulatkannya. Tetapi bilangan bulat 30 digit tidak muat di long, dan angka yang dikirim sebagai string bergantung pada opsi Anda. ReadFromJsonAsync memakai default web, yang menerimanya; opsi tanpa NumberHandling.AllowReadingFromString tidak menerima.
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };
using var response = await http.GetAsync("/api/edge-cases/numbers");
var json = await response.Content.ReadAsStringAsync();
Console.WriteLine(JsonSerializer.Serialize(
JsonSerializer.Deserialize<Numbers>(json, JsonSerializerOptions.Web)));
// The same field, read with the web defaults and without them.
var strict = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
foreach (var (label, options) in new[] { ("Web defaults", JsonSerializerOptions.Web), ("Without web defaults", strict) })
{
try
{
var value = JsonSerializer.Deserialize<AsString>(json, options);
Console.WriteLine($"{label}: \"9223372036854775807\" read as {value!.Int64MaxAsString}");
}
catch (JsonException ex) { Console.WriteLine($"{label}: JsonException at {ex.Path}"); }
}
try { JsonSerializer.Deserialize<TooBig>(json, JsonSerializerOptions.Web); }
catch (JsonException ex) { Console.WriteLine($"bigInteger into long: JsonException at {ex.Path}"); }
record Numbers(long Int64Max, long BeyondSafeInteger, double PointOnePlusPointTwo);
record AsString(long Int64MaxAsString);
record TooBig(long BigInteger);{"Int64Max":9223372036854775807,"BeyondSafeInteger":9007199254740993,"PointOnePlusPointTwo":0.30000000000000004}
Web defaults: "9223372036854775807" read as 9223372036854775807
Without web defaults: JsonException at $.int64MaxAsString
bigInteger into long: JsonException at $.bigIntegerPembaca yang aman: satu error yang jelas untuk setiap body buruk
Baca body sebagai string lebih dulu, agar error bisa mengutipnya, lalu parse. Setiap kegagalan menjadi InvalidDataException yang menyebut URL, content type, di mana parsing berhenti, dan awal body. Itulah yang Anda butuhkan di log pukul 3 pagi.
using System.Text.Json;
public static class JsonResponses
{
// Web defaults (camelCase, case-insensitive, numbers in strings allowed), but a repeated
// property is an error instead of the last value silently winning (.NET 10).
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
AllowDuplicateProperties = false,
};
/// Reads a JSON body, or throws an InvalidDataException that says what came back and where it broke.
public static async Task<T> ReadJsonAsync<T>(this HttpResponseMessage response, CancellationToken cancellationToken = default)
{
response.EnsureSuccessStatusCode();
var url = response.RequestMessage?.RequestUri;
var contentType = response.Content.Headers.ContentType?.ToString() ?? "no content type";
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(body))
{
throw new InvalidDataException($"{(int)response.StatusCode} from {url} had an empty body ({contentType}).");
}
try
{
return JsonSerializer.Deserialize<T>(body, Options)
?? throw new InvalidDataException($"{url} returned JSON null.");
}
catch (JsonException ex)
{
var preview = body.Length > 120 ? body[..120] + "..." : body;
throw new InvalidDataException(
$"Invalid JSON from {url} ({contentType}) at {ex.Path}, line {ex.LineNumber}, byte {ex.BytePositionInLine}: {preview}", ex);
}
}
}using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };
foreach (var path in new[] { "/api/Products/1", "/api/formats/malformed-json", "/api/formats/empty", "/api/edge-cases/duplicate-keys" })
{
try
{
using var response = await http.GetAsync(path);
var item = await response.ReadJsonAsync<Item>();
Console.WriteLine($"OK: {item}");
}
catch (InvalidDataException ex)
{
Console.WriteLine(ex.Message);
}
}
record Item(int Id);OK: Item { Id = 1 }
Invalid JSON from https://api.testingapis.com/api/formats/malformed-json (application/json; charset=utf-8) at $.tags, line 0, byte 45: {"id": 1, "name": "Alice", "tags": ["a", "b",], "active": tru
200 from https://api.testingapis.com/api/formats/empty had an empty body (no content type).
Invalid JSON from https://api.testingapis.com/api/edge-cases/duplicate-keys (application/json; charset=utf-8) at $.id, line 5, byte 7: {
"id": 1,
"name": "first value",
"name": "second value",
"nested": { "flag": true, "flag": false },
"id": 2
}Duplikatnya dilaporkan di $.id, baris 5: Item hanya punya Id, jadi itulah properti berulang yang penting baginya. Key yang tidak dipetakan tipe itu dilewati bagaimanapun juga.
Tes xUnit
Satu theory mencakup setiap response yang rusak; satu fact memeriksa bahwa JSON yang baik tetap terbaca. Lulus dengan xUnit 2.9 di .NET 10, memakai kelas JsonResponses di atas.
using Xunit;
public class JsonResponseTests
{
private static readonly HttpClient Http = new() { BaseAddress = new Uri("https://api.testingapis.com") };
private record Item(int Id);
[Theory]
[InlineData("/api/formats/malformed-json")]
[InlineData("/api/formats/empty")]
[InlineData("/api/edge-cases/duplicate-keys")]
[InlineData("/api/edge-cases/top-level/string")]
public async Task Bad_json_becomes_one_clear_error(string path)
{
using var response = await Http.GetAsync(path);
var ex = await Assert.ThrowsAsync<InvalidDataException>(() => response.ReadJsonAsync<Item>());
Assert.Contains(path, ex.Message); // the message says which call broke
}
[Fact]
public async Task Good_json_still_reads()
{
using var response = await Http.GetAsync("/api/Products/1");
var item = await response.ReadJsonAsync<Item>();
Assert.Equal(1, item.Id);
}
}Yang perlu diperiksa di klien Anda
- JSON tidak valid dengan status 200 adalah error, bukan crash: ditangkap, dicatat dengan URL, ditampilkan ke pengguna.
- Body kosong atau 204 ditangani sebelum parsing, di mana API bisa mengirimnya.
- Key duplikat ditolak di tempat "yang terakhir menang" secara diam-diam akan menyembunyikan bug.
- ID besar muat di tipenya:
long, ataustringbila bisa melampauinya. - Error mengutip cukup banyak body untuk mendiagnosisnya, tanpa mencatat rahasia.
Response yang sama juga berguna di bahasa lain: referensi kasus tepi JSON dan referensi format mendaftar semuanya, dan error sesuai permintaan membahas kode status kegagalan.