第54章:構造まとめミニ演習:Stream系でDecorator/Adapter/Facadeを一気に回収🎉
ねらい 🎯
Streamまわりの“定番構成”で、Decorator / Adapter / Facade をまとめて体に入れるよ💪✨- 「読み書き・圧縮・テキスト化・DTO変換」みたいなよくある処理の重なりを、スッキリ扱えるようになるよ🧹🌸
- “変換層に業務ロジックを混ぜない”を守る練習もするよ🚧🙂
到達目標 ✅
FileStream → (Decorator) GZipStream → (Adapter) StreamReader → Json → DTO → ドメインの流れを説明できる📣Facadeとして「呼び出し側が楽になる入口」を1つ作れる🚪✨Dispose(using)を外さず、ファイル/ストリームを安全に扱える🧯- テスト(MSTest)で「圧縮→復元→同じ内容」を確認できる🧪💕
手順 🧭
1) 今回の題材を決める(“gz圧縮JSON Lines”)📦🧵
今回は gzip(.gz)で圧縮された JSON Lines(1行=1注文) を入出力するよ🙂
- 1行ずつ読めるので、巨大ファイルでも“全部メモリに載せない”方向にしやすい✨
- そして StreamReader/Writer が活躍する(=Adapterを見せやすい)🫶
StreamReaderは、バイト列のStreamを文字列の世界(テキスト)に橋渡ししてくれる代表的な存在だよ📚✨(=Adapterの感覚)(Microsoft Learn)
2) 「どこが何パターン?」対応づけメモ 🧠📝

- Decorator:
GZipStreamでStreamを包んで「圧縮/解凍」を後付け🎁 - Adapter:
StreamReader / StreamWriterで「Stream ↔ テキスト」を変換🔌 - Facade:複雑な手順(Open→Wrap→Read→Deserialize→Map)を
OrderArchiveみたいな窓口にまとめる🚪✨
3) 最小のモデル(ドメイン/DTO)を用意する 🛒🍰
既に Order / Money があるなら、そこに合わせてOK!
ここでは“教材用の最小”を置いておくね🙂
using System;
using System.Collections.Generic;
public readonly record struct Money(decimal Amount, string Currency);
public sealed record Order(
string OrderId,
Money Total,
IReadOnlyList<OrderLine> Lines
);
public sealed record OrderLine(
string Sku,
int Quantity,
Money UnitPrice
);
// JSON用DTO(“保存形式”なので、ドメインよりシンプルでOK)
public sealed record OrderDto(
string OrderId,
decimal TotalAmount,
string Currency,
List<OrderLineDto> Lines
);
public sealed record OrderLineDto(
string Sku,
int Quantity,
decimal UnitPrice
);
4) Facade(窓口)を作る:OrderArchive 🚪✨
ポイントはこれ👇
- 呼び出し側は **「Import/Export を呼ぶだけ」**にする
- 中では
Streamを組み合わせる(Decorator/Adapter) - DTO変換は **“変換だけ”**にして、業務判断は混ぜない🙅♀️💦
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Runtime.CompilerServices;
public static class OrderArchive
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
// ===== Facade:ファイルから読む(入口)=====
public static async Task<IReadOnlyList<Order>> ImportFromGZipJsonLinesFileAsync(
string path,
CancellationToken ct = default)
{
await using var file = File.OpenRead(path);
// “全部読み切って List にする”版(呼び出し側が扱いやすい)
var result = new List<Order>();
await foreach (var order in ImportFromGZipJsonLinesAsync(file, ct))
{
result.Add(order);
}
return result;
}
// ===== Facade:ファイルへ書く(入口)=====
public static async Task ExportToGZipJsonLinesFileAsync(
string path,
IEnumerable<Order> orders,
CancellationToken ct = default)
{
await using var file = File.Create(path);
await ExportToGZipJsonLinesAsync(file, orders, ct);
}
// ===== 中核:Streamから読む(Decorator + Adapter + DTO変換)=====
public static async IAsyncEnumerable<Order> ImportFromGZipJsonLinesAsync(
Stream source,
[EnumeratorCancellation] CancellationToken ct = default)
{
// Decorator:Stream を “解凍できるStream” に変える
using var gzip = new GZipStream(source, CompressionMode.Decompress, leaveOpen: true);
// Adapter:Stream を “行で読めるテキスト” に変える
using var reader = new StreamReader(
gzip,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: true,
bufferSize: 1024,
leaveOpen: true);
while (true)
{
ct.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync().ConfigureAwait(false);
if (line is null) yield break;
if (string.IsNullOrWhiteSpace(line)) continue;
var dto = JsonSerializer.Deserialize<OrderDto>(line, JsonOptions)
?? throw new InvalidDataException("Invalid JSON line (OrderDto).");
yield return FromDto(dto);
}
}
// ===== 中核:Streamへ書く(Decorator + Adapter + DTO変換)=====
public static async Task ExportToGZipJsonLinesAsync(
Stream destination,
IEnumerable<Order> orders,
CancellationToken ct = default)
{
// Decorator:Stream を “圧縮して書けるStream” に変える
using var gzip = new GZipStream(destination, CompressionLevel.SmallestSize, leaveOpen: true);
// Adapter:Stream を “行で書けるテキスト” に変える
using var writer = new StreamWriter(
gzip,
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
bufferSize: 1024,
leaveOpen: true);
foreach (var order in orders)
{
ct.ThrowIfCancellationRequested();
var dto = ToDto(order);
var json = JsonSerializer.Serialize(dto, JsonOptions);
await writer.WriteLineAsync(json).ConfigureAwait(false);
}
await writer.FlushAsync().ConfigureAwait(false);
// ※ Dispose の順で gzip のフッターもちゃんと出るよ
}
// ===== DTO変換(ここに業務ロジックを入れないのがコツ)=====
private static OrderDto ToDto(Order order)
{
var lines = new List<OrderLineDto>(order.Lines.Count);
foreach (var l in order.Lines)
{
lines.Add(new OrderLineDto(l.Sku, l.Quantity, l.UnitPrice.Amount));
}
return new OrderDto(
order.OrderId,
order.Total.Amount,
order.Total.Currency,
lines
);
}
private static Order FromDto(OrderDto dto)
{
var lines = new List<OrderLine>(dto.Lines.Count);
foreach (var l in dto.Lines)
{
lines.Add(new OrderLine(l.Sku, l.Quantity, new Money(l.UnitPrice, dto.Currency)));
}
return new Order(
dto.OrderId,
new Money(dto.TotalAmount, dto.Currency),
lines
);
}
}
StreamWriterのコンストラクターは「ストリーム+エンコーディング+バッファ」みたいに受け取れて、テキスト出力の入口になってくれるよ🖊️✨(Microsoft Learn)System.Text.JsonのJsonSerializerは、DTOへの変換で“標準ど真ん中”! Stream から直接読むDeserializeAsyncも用意されてるよ📦✨(Microsoft Learn)
5) 呼び出し側は、こうなる(Facadeのご褒美)🍬
var orders = await OrderArchive.ImportFromGZipJsonLinesFileAsync(@"C:\work\orders.jsonl.gz");
await OrderArchive.ExportToGZipJsonLinesFileAsync(@"C:\work\orders_copy.jsonl.gz", orders);
呼び出し側は Streamの重ね着を知らなくてOK!これがFacadeの嬉しさだよ🚪💕
6) テストで「圧縮→復元」を確認する 🧪✨(MSTest)
ファイルを使わず、MemoryStream でサクッとテストするのが気楽だよ🙂
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
[TestClass]
public class OrderArchiveTests
{
[TestMethod]
public async Task Export_and_Import_roundtrip_should_keep_orders()
{
var orders = new List<Order>
{
new(
"ORD-001",
new Money(1200m, "JPY"),
new List<OrderLine>
{
new("SKU-AAA", 2, new Money(300m, "JPY")),
new("SKU-BBB", 1, new Money(600m, "JPY"))
}
)
};
await using var ms = new MemoryStream();
// 圧縮して書く
await OrderArchive.ExportToGZipJsonLinesAsync(ms, orders);
// 読む前に先頭へ戻す
ms.Position = 0;
var restored = new List<Order>();
await foreach (var o in OrderArchive.ImportFromGZipJsonLinesAsync(ms))
{
restored.Add(o);
}
Assert.AreEqual(1, restored.Count);
Assert.AreEqual("ORD-001", restored[0].OrderId);
Assert.AreEqual(1200m, restored[0].Total.Amount);
Assert.AreEqual("JPY", restored[0].Total.Currency);
Assert.AreEqual(2, restored[0].Lines.Count);
}
}
7) (発展)JsonSerializer.DeserializeAsync で Stream から直読みする案 🌊
もしファイル形式を「JSON Lines」じゃなく JSON配列([ {...}, {...} ])にできるなら、Streamから直接デシリアライズもできるよ✨(Microsoft Learn)
さらに“巨大配列をちょっとずつ”なら DeserializeAsyncEnumerable<T> も選べる(JSON配列を逐次列挙)🌟(Microsoft Learn)
8) セキュリティ注意(超だいじ)🛡️⚠️
Streamで「オブジェクト丸ごと保存したい…」ってなると、昔の癖で BinaryFormatter に行きがちだけど、セキュリティ的に危険で非推奨だから避けようね🙅♀️💦(Microsoft Learn)
あと、.NET は月次で更新(Patch Tuesday)されるので、ランタイム/SDKは新しいパッチに追従が安心だよ🔧✨(Microsoft)
よくある落とし穴 🕳️💦
- 変換層(DTO変換)に業務ルールを混ぜる 「通貨がJPYなら割引」みたいなのをここに入れると、Facadeが太って壊れやすい😵💫
Disposeを忘れて gzip の最後が出ない gzipは最後にフッターを書くので、usingが命だよ🧯- テストで
MemoryStream.Position = 0;を忘れる 書いた後は末尾にいるので、読めなくなるあるある😇 - “便利そう”で例外握りつぶし 失敗した行があるなら、最低限「どの行で死んだか」くらいは分かるようにしたい📣
ミニ演習(10〜30分)🎮✨
- ✅ 上の
OrderArchiveをコピペではなく 自分で打って動かす(型が頭に残るよ)🧠💡 - ✅ テストを1本通す(Roundtrip)🧪
- 🌟 余裕があれば:空行が混じっても壊れないようにして、テストを1つ足す🙂
- 🌟 さらに余裕があれば:
ImportFromGZipJsonLinesFileAsyncの戻りをIAsyncEnumerable<Order>版にして、「逐次処理」っぽさを体験する🚶♀️✨
自己チェック ✅🌸
- Decorator(
GZipStream)と Adapter(StreamReader/Writer)を どの行で使ってるか言える?🎁🔌 - 呼び出し側が「Open→Wrap→Read…」を知らずに済んでる?(Facadeできてる?)🚪
- DTO変換に、業務ロジック(割引/在庫/状態遷移)を混ぜてない?🧼
- テストで「圧縮→復元」が通ってる?🧪🎉