流与 JSON 序列化
本教程共 100 篇 · 第 96 篇 · 更新于 2026-07-31 · 约 9 分钟阅读
96. 流与 JSON 序列化
本节目标:学完能说清 Stream 是什么,用内存流和文件流读写字节,并把对象序列化成 JSON 字符串或从 JSON 还原成对象。
前面各章读写的文本,底层其实都流经一种东西——流(Stream)。理解了流,你就握住了 C# 所有 IO 的总开关。本章再顺手讲清日常最常用的 JSON 序列化。
一、Stream 是什么,为什么这样设计
Stream 是一个抽象基类,代表「一串可以按顺序读写的字节」。它不关心字节来自文件、内存还是网络。常见派生类有:
FileStream:字节来自磁盘文件。MemoryStream:字节来自内存里的一块缓冲区。NetworkStream:字节来自网络连接(属网络编程,略过)。
Note正因为
FileStream、MemoryStream都继承自Stream,上层代码可以「面向 Stream 编程」——只认Stream,不认数据从哪来。换数据源时,调用方一行都不用改。
为什么要做成统一抽象?打个比方:流像一根水管,数据像水。你只管「接水管、放水、收水」,至于水管那一头接着水库还是水塔,你不必关心。这种「只认接口、不认来源」的设计,让代码能自由拼装:比如在 FileStream 外面再包一层压缩流、加密流,就成了一根「会压缩、会加密的水管」,而读写逻辑完全不变。这就是流的真正威力——可组合、可替换。
二、MemoryStream:在内存里折腾字节
MemoryStream 不直接碰磁盘,适合做临时缓冲、单元测试、或把数据在内存里转来转去后再决定去向。
namespace CSharpDemo;
using var ms = new MemoryStream();
using var writer = new StreamWriter(ms);
writer.Write("hello");
writer.Flush(); // 确保数据进了底层缓冲区
Console.WriteLine($"内存流长度:{ms.Length} 字节");
// 回到开头读出来
ms.Position = 0;
using var reader = new StreamReader(ms);
Console.WriteLine(reader.ReadToEnd()); // hello
Note写入后要把
Position重置回 0 才能从头读;Flush保证缓冲数据真正写入底层。这两个细节新手常漏,漏了读出来就是空。
MemoryStream 的典型适用场景:① 临时拼装一段二进制或文本,拼好再一次性落盘;② 单元测试里替代真实文件,跑得快又不污染磁盘;③ 网络收发时先做中间缓冲。一句话:凡是「先在内存里鼓捣,最后才决定去哪」的活,它最合适。
下面演示流的「管道」能力——把一段字节从一个流直接复制到另一个流:
namespace CSharpDemo;
// 把源流的内容复制进目标流(典型管道操作)
using var source = new MemoryStream([1, 2, 3, 4]);
using var dest = new MemoryStream();
source.CopyTo(dest);
Console.WriteLine($"复制了 {dest.Length} 字节");
CopyTo 是 Stream 自带的方法。正因为它操作的是抽象 Stream,所以「复制」逻辑既适用于内存流,也适用于文件流、网络流,不用为每种来源重写一遍。
三、FileStream:直接读写磁盘文件
FileStream 把流接到磁盘文件,适合需要精确控制读写的场景,比如只改写文件中间一段,或自己管理缓冲与共享锁。
namespace CSharpDemo;
using var fs = new FileStream("data.bin", FileMode.Create);
fs.Write(new byte[] { 1, 2, 3 }); // 写入 3 个字节
Console.WriteLine($"文件长度:{fs.Length}");
FileMode 决定打开方式:Create 覆盖建新、Append 追加到末尾、Open 只读打开、OpenOrCreate 有则开无则建。FileAccess 还能进一步限制只读/只写/读写,FileShare 控制别的进程能否同时访问同一文件。
Warning
FileStream占用的是真实磁盘句柄。忘了释放(没用using),文件会被一直锁住,别处删不掉也改不了。养成「凡是 Stream 一律using」的习惯,最省心。
日常若只是整篇读写文本,第 95 章的 File 类更省事;要精细控制字节、偏移、共享锁,才上 FileStream。
四、System.Text.Json:对象变 JSON
JSON 是程序之间交换数据的通用格式,浏览器、接口、配置文件都在用。.NET 内置 System.Text.Json,高性能且无需第三方库。
Note
System.Text.Json不在默认隐式 using 里。本教程统一用全限定名System.Text.Json.JsonSerializer(也避免写显式using System.Text.Json;),保持与第 95 章一致的代码风格。
namespace CSharpDemo;
class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
}
var p = new Product { Id = 1, Name = "机械键盘", Price = 9.99m };
// 序列化:对象 → JSON 字符串
string json = System.Text.Json.JsonSerializer.Serialize(p);
Console.WriteLine(json); // {"Id":1,"Name":"机械键盘","Price":9.99}
// 反序列化:JSON 字符串 → 对象
var back = System.Text.Json.JsonSerializer.Deserialize<Product>(json);
Console.WriteLine($"{back?.Name} 售价 {back?.Price}");
序列化(serialization)是把内存里的对象「拍平」成文本;反序列化(deserialization)反过来,把文本还原成对象。为什么需要它?因为对象活在内存里,程序一关就没了;想通过网络发出去、写进文件、或交给别的语言读,就得先变成大家都能懂的 JSON。
想让输出更好看、字段名按 Web 习惯变小写,可用 JsonSerializerOptions:
namespace CSharpDemo;
var options = new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true, // 缩进美化
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase // 字段名转 camelCase
};
var p = new Product { Id = 1, Name = "机械键盘", Price = 9.99m };
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(p, options));
// {
// "id": 1,
// "name": "机械键盘",
// "price": 9.99
// }
Tip内置的
System.Text.Json是默认首选,速度快、零依赖。社区里老的Newtonsoft.Json功能更全但需额外装包。新手先用内置的,够用且省心。
序列化常见配置
除了缩进和命名策略,JsonSerializerOptions 还有几个常用开关,按需打开:
PropertyNamingPolicy = JsonNamingPolicy.CamelCase:字段名转 camelCase,对接 Web 接口时最常用。WriteIndented = true:美化输出、带缩进,便于人眼阅读与排查。DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull:序列化时跳过null字段,让 JSON 更干净。IncludeFields = true:默认只序列化属性,设true也会带上公共字段。
Tip给某个字段自定义 JSON 名,可用
[JsonPropertyName("user_name")]特性,它的优先级高于全局命名策略。需要彻底忽略某字段,用[JsonIgnore]。这两个特性在对接第三方接口、字段名对不上时特别有用,能让你在「不改类定义」的前提下适配外部格式。
五、直接序列化到流
更常见的做法是不经过字符串,直接读写流,省一次内存拷贝:
namespace CSharpDemo;
class Item { public int Id { get; set; } public string Name { get; set; } = ""; }
var p = new Item { Id = 1, Name = "鼠标" };
using var fs = File.Create("product.json");
System.Text.Json.JsonSerializer.Serialize(fs, p); // 直接写进文件流
using var readFs = File.OpenRead("product.json");
var loaded = System.Text.Json.JsonSerializer.Deserialize<Item>(readFs);
Console.WriteLine(loaded?.Name);
六、流、字节数组、字符串,怎么选
三者都能装数据,别搞混:
| 形态 | 特点 | 适用 |
|---|---|---|
byte[] 字节数组 | 一次性在内存,随机访问快 | 数据小、要反复改 |
string 字符串 | 文本、不可变 | 纯文本、拼报文 |
Stream 流 | 顺序读写、可来自任意源 | 大文件、网络、管道 |
简单说:数据小又爱改,用数组;纯文本,用字符串;数据大、来源杂、要边读边写,用流。它们也能互转:stream.ToArray() 把流变成字节数组,Encoding.UTF8.GetString(bytes) 把字节变成字符串。
七、新手踩坑
MemoryStream写完后忘了Flush或重置Position,读出来是空的。- 以为
System.Text.Json默认可用,结果编译报「找不到 JsonSerializer」。记得用全限定名System.Text.Json.JsonSerializer,或加using System.Text.Json;。 - 反序列化返回
null时直接访问属性,抛NullReferenceException。用?.安全访问。 - 属性名默认按原样输出。想自定义 JSON 字段名,用
[JsonPropertyName("name")]特性。 - 把
FileStream当文本读写却用错编码。文本场景优先用StreamReader/StreamWriter,它们默认 UTF-8。 - 忘了
using释放流,导致文件被锁。所有流都套using(或using var声明)。
Warning流式读写是「顺序」的。读了一半又把
Position随便挪来挪去,极易读错位置。需要随时随机访问,别用流,改用byte[]或File.ReadAllBytes。
八、本节小结
Stream 是 C# IO 的统一抽象:MemoryStream 玩内存、FileStream 落磁盘,都套着 StreamReader/StreamWriter 读写文本。对象与 JSON 之间用 System.Text.Json 互转,还能直接对接流省去中间字符串。把「流、编码、using 释放」三件事记牢,IO 就不再是拦路虎。下一章跳出单文件,看整个项目的组织方式。