【実務・中級編】VB.NETにおけるXML/JSONシリアライゼーションの落とし穴:Newtonsoft.JsonやSystem.Text.Jsonを用いた、Windows Formsアプリの設定ファイル・通信データ永続化 – Visual Basic (VB / VB.NET)解析バイブル

スポンサーリンク
スポンサーリンク

データの永続化を「たった一行」で済ませるな:VB.NETにおけるシリアライゼーションの極限設計

Windows Forms(WinForms)を用いた業務アプリケーション開発において、設定ファイルや通信データの保存・復元は避けて通れない工程だ。多くの開発者は、`Newtonsoft.Json` や `System.Text.Json` を使い、まるで魔法のように `JsonConvert.SerializeObject(obj)` と一行書けば済むと考えている。

だが、現場で「動く」ことと、業務システムとして「堅牢である」ことは全く別物だ。

複雑なネスト構造を持つ業務データを扱う際、その「一行」が原因で、循環参照によるスタックオーバーフロー、プロパティの消失、あるいはUIスレッドのフリーズを引き起こし、現場の業務を止める。

今日は、私が数々の炎上案件を鎮火させてきた経験から、VB.NETにおけるシリアライゼーションの「落とし穴」と、プロフェッショナルが実践すべき「設計の鉄則」を伝授する。

1. 循環参照という「静かなる爆弾」

業務データは往々にして、オブジェクト同士が互いを知っている(双方向参照)状態にある。例えば、`部署(Department)` クラスが `社員(Employee)` のリストを持ち、逆に `社員` クラスが所属する `部署` を参照しているような構造だ。

これをそのままシリアライズしようとすると、シリアライザは「部署 → 社員 → 部署 → 社員…」と無限ループに陥り、最終的に `StackOverflowException` を吐いてアプリケーションを強制終了させる。これは `Try…Catch` では捕捉できない、致命的な死だ。

【解決策】
1. DTO(Data Transfer Object)の分離: ドメインモデルを直接シリアライズせず、シリアライズ専用の「平坦なクラス」を用意する。これが最もクリーンな設計だ。
2. 参照設定の利用: `Newtonsoft.Json` の `ReferenceLoopHandling.Ignore` を使う方法もあるが、これはデータの整合性を損なうリスクがある。設計レベルでの解決を優先せよ。

2. 「プロパティ」と「フィールド」の境界線

VB.NETにおいて、`Public MyData As String` と `Public Property MyData As String` は、初心者には同じに見えるだろう。しかし、シリアライザにとっては天と地ほどの差がある。

多くのモダンなシリアライザ(特に `System.Text.Json`)は、デフォルトでは「パブリックなプロパティ」のみを対象とし、フィールドは無視する。

「設定を保存したはずなのに、次回の起動時に値が初期化されている」という不可解なバグの 9 割は、このプロパティとフィールドの混同、あるいは `ReadOnly` プロパティへの書き込み失敗に起因している。

3. Case Sensitivity(大文字・小文字)の罠

`System.Text.Json` は `Newtonsoft.Json` に比べて非常に厳格だ。デフォルトでは、JSONのキーが大文字・小文字を厳密に区別する。

外部APIから受け取った JSON が `userName` なのに、VB.NET 側のプロパティが `UserName` である場合、シリアライズは成功してもデシリアライズ(復元)時に値が `Nothing` になる。これは「バグではないが、仕様として破綻している」状態だ。

4. プロダクションレベルの設計:堅牢なシリアライゼーション・エンジン

では、どのように実装すべきか。単なる関数ではなく、エラーハンドリング、ファイルI/Oの安全性、そしてUIスレッドへの配慮を組み込んだ「サービス層」を構築する設計例を示す。

ここでは、最新の標準である `System.Text.Json` をベースに、実務でそのまま使えるアーキテクチャを提示する。

Imports System.IO
Imports System.Text.Json
Imports System.Text.Json.Serialization
Imports System.Threading.Tasks

''' <summary>
''' 業務データ用DTO。ドメインモデルとは別に定義し、
''' 循環参照や不要なプロパティを排除した「保存専用」の構造にする。
''' </summary>
Public Class AppConfigDto
    <JsonPropertyName("app_name")>
    Public Property ApplicationName As String = "Default App"

    <JsonPropertyName("last_user")>
    Public Property LastUserName As String = ""

    <JsonPropertyName("window_size")>
    Public Property WindowSize As WindowSizeDto = New WindowSizeDto()
End Class

Public Class WindowSizeDto
    <JsonPropertyName("width")>
    Public Property Width As Integer = 800

    <JsonPropertyName("height")>
    Public Property Height As Integer = 600
End Class

''' <summary>
''' 設定管理の司令塔。
''' 単一責任の原則に基づき、ファイル操作とシリアライズを隠蔽する。
''' </summary>
Public Class ConfigurationManager
    Private ReadOnly _filePath As String
    Private ReadOnly _options As JsonSerializerOptions

    Public Sub New(fileName As String)
        ' AppDataフォルダなど、適切な書き込み権限のあるパスを使用すること
        Dim appData As String = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)
        Dim targetDir = Path.Combine(appData, "MyBusinessApp")
        
        If Not Directory.Exists(targetDir) Then
            Directory.CreateDirectory(targetDir)
        End If
        
        _filePath = Path.Combine(targetDir, fileName)

        ' プロフェッショナルな設定:インデント付け、大文字小文字の許容、列挙型の文字列変換
        _options = New JsonSerializerOptions() With {
            .WriteIndented = True,
            .PropertyNameCaseInsensitive = True,
            .Converters = { New JsonStringEnumConverter() }
        }
    End Sub

    ''' <summary>
    ''' 非同期で設定を保存する。WinFormsのUIスレッドをブロックしないための必須テクニック。
    ''' </summary>
    Public Async Function SaveConfigAsync(config As AppConfigDto) As Task(Of Boolean)
        Try
            ' 一時ファイルに書き込んでからリネームする手法(Atomic Write)
            ' これにより、書き込み中のクラッシュによるファイル破損を防ぐ
            Dim tempFile = _filePath & ".tmp"
            
            Dim jsonString = JsonSerializer.Serialize(config, _options)
            
            ' File.WriteAllTextAsync を使用してI/O待ちによるUIフリーズを回避
            Await File.WriteAllTextAsync(tempFile, jsonString)

            If File.Exists(_filePath) Then File.Delete(_filePath)
            File.Move(tempFile, _filePath)

            Return True
        Catch ex As Exception
            ' ここでロギングを行う(EventLogやファイルへの書き出し)
            Debug.WriteLine($"[Error] Config Save Failed: {ex.Message}")
            Return False
        End Try
    End Function

    ''' <summary>
    ''' 設定を読み込む。ファイルが存在しない場合はデフォルトを返す。
    ''' </summary>
    Public Async Function LoadConfigAsync() As Task(Of AppConfigDto)
        If Not File.Exists(_filePath) Then
            Return New AppConfigDto() ' デフォルト値を返す
        End If

        Try
            Dim jsonString = Await File.ReadAllTextAsync(_filePath)
            Dim config = JsonSerializer.Deserialize(Of AppConfigDto)(jsonString, _options)
            
            Return If(config, New AppConfigDto())
        Catch ex As Exception
            Debug.WriteLine($"[Error] Config Load Failed: {ex.Message}")
            Return New AppConfigDto()
        End Try
    End Function
End Class

実装のポイント解説

1. DTO(Data Transfer Object)の採用:
`AppConfigDto` を定義することで、ビジネスロジック(ドメイン)と永続化形式(DTO)を完全に切り離している。これにより、将来的にクラス構造が変わっても、JSONの互換性を保つことが容易になる。
2. Atomic Write(原子的な書き込み):
`tempFile` に一度書き出してから `Move` する手法は、極めて重要だ。書き込み中にPCの電源が落ちたり、アプリがクラッシュしたりしても、元の設定ファイルが壊れるのを防げる。
3. Async/Await による UI 非同期化:
WinFormsで `File.WriteAllText` を使うと、ファイルサイズが大きくなった際に「アプリが数秒固まる」現象が発生する。`Async` を使い、I/O待ちの間もメッセージループを回し続けるのがプロの作法だ。
4. JsonSerializerOptions の明示的設定:
`PropertyNameCaseInsensitive = True` を設定することで、JSONの表記揺れに対する耐性を高めている。

結論:設計がバグを未然に防ぐ

シリアライゼーションは、単なる「データの変換」ではない。それは「アプリケーションの状態を、時間を超えて、環境を超えて保存する」という、極めて重要な責任を伴う行為だ。

「とりあえず動く」コードを書くのは誰にでもできる。しかし、数年後の保守担当者が、あるいは過酷な運用環境に置かれたユーザーが、そのツールを安心して使えるかどうかは、君が今、設計の細部にどれだけの知見を注ぎ込めるかにかかっている。

DTOを使い、非同期I/Oを徹底し、不完全な状態での書き込みを避ける。この基本を徹底せよ。それが、伝説的なアーキテクトへの第一歩だ。

タイトルとURLをコピーしました