Go言語で実現するメモリ効率的なJSON逐次処理の方法

Go言語は、そのシンプルで効率的なデザインにより、スケーラブルなアプリケーション開発に適したプログラミング言語として広く使われています。特に、JSONデータの処理において、その軽量性と効率性が強調される場面が多くあります。しかし、大規模なJSONデータを扱う際には、メモリ使用量の制御が重要になります。本記事では、Go言語の特性を活かし、メモリ効率を意識したJSONデータの逐次処理技術について解説します。これにより、システム負荷を軽減し、信頼性の高いデータ処理を実現する方法を学ぶことができます。

目次

Go言語とJSON処理の基本


Go言語は標準ライブラリで強力なJSON処理機能を提供しています。特に、encoding/jsonパッケージを使用することで、JSONデータのエンコード(データをJSON形式に変換)やデコード(JSON形式をGoのデータ構造に変換)が簡単に実現できます。

JSONエンコードとデコードの基本


Goでは、以下のようにJSONのエンコードとデコードが行われます:

JSONのエンコード


Goの構造体やマップをJSON形式に変換するには、json.Marshal関数を使用します。例:

type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

func main() {
    person := Person{Name: "Alice", Age: 30}
    jsonData, err := json.Marshal(person)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(jsonData))
}

JSONのデコード


JSON文字列をGoのデータ構造に変換するには、json.Unmarshal関数を使用します。例:

func main() {
    jsonData := `{"name": "Alice", "age": 30}`
    var person Person
    err := json.Unmarshal([]byte(jsonData), &person)
    if err != nil {
        panic(err)
    }
    fmt.Printf("%+v\n", person)
}

GoのJSON処理における特徴

  1. 型の明確性: JSONデータをマッピングする構造体を事前に定義することで、型安全な操作が可能です。
  2. フィールドタグ: 構造体のフィールドにjsonタグを付けることで、カスタムのキー名を設定できます。
  3. シンプルなエラーハンドリング: JSONの構造が期待と異なる場合、明示的なエラーが発生します。

Goの基本的なJSON処理を理解することで、効率的なデータ管理の土台が構築できます。次に、これらの基本を踏まえて、大規模なJSONデータを効率的に処理するための手法に進みます。

メモリ効率を意識したデータ処理の必要性

現代のアプリケーションでは、膨大なJSONデータを扱うことが珍しくありません。特にAPI通信やログデータの処理では、数MBから数GBにも及ぶJSONデータを迅速かつ正確に処理することが求められます。しかし、単純な方法でデータを一括処理すると、メモリ不足やパフォーマンス低下といった問題が発生します。

従来の一括処理の課題


JSONデータを一括で読み込み、処理する場合の主な問題点は以下の通りです:

  1. メモリ消費の増大: 大規模なJSONデータを一度にメモリにロードすると、アプリケーションのメモリ使用量が急激に増加します。
  2. スケーラビリティの低下: データ量の増加に伴い、システム全体のパフォーマンスが低下するリスクがあります。
  3. エラーハンドリングの困難さ: 大きなJSONデータを処理中にエラーが発生した場合、エラーの原因特定や部分的な修復が難しくなります。

逐次処理の利点


逐次処理を活用することで、これらの課題を効果的に解決できます。

  1. 低メモリ消費: 必要なデータを部分的にメモリに読み込むため、メモリ使用量を最小限に抑えられます。
  2. 効率的な処理: データを逐次的に処理することで、処理速度が向上し、スケーラブルな設計が可能です。
  3. リアルタイム性: ログ処理やAPIストリームなど、リアルタイムでのデータ処理に適しています。

具体的なユースケース

  1. ビッグデータの分析: JSON形式のログデータを分割して処理し、必要な情報を効率的に抽出。
  2. リアルタイム処理: IoTデバイスやウェブサービスからストリームデータを逐次的に受け取り処理。
  3. APIレスポンスの分割処理: 大量のデータを返すAPIレスポンスを部分的に読み込んで表示や加工を実施。

このように、メモリ効率を考慮した処理は、安定したシステム運用の鍵となります。次章では、この逐次処理を支えるGo言語の技術について詳しく解説します。

JSONデコードの基礎:逐次処理の仕組み

Go言語では、大規模なJSONデータを効率的に処理するための強力なツールとしてjson.Decoderが提供されています。これは、JSONデータをストリームとして読み込み、必要な部分を逐次的に処理することを可能にします。

逐次処理の基本概念


json.Decoderは、JSONデータをバイトストリームとして受け取り、指定された単位でデコードします。一括読み込みを避けることで、メモリ使用量を最小限に抑える設計になっています。特に以下のような場面で有効です:

  • JSONデータが非常に大きい場合
  • ストリーム(ネットワークやファイル)からJSONデータを受け取る場合

`json.Decoder`の使用方法


基本的な使い方を以下のコードで説明します。

サンプルコード:JSON配列の逐次デコード

package main

import (
    "encoding/json"
    "fmt"
    "os"
)

type Record struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

func main() {
    // サンプルのJSONデータ
    jsonData := `[
        {"id": 1, "name": "Alice"},
        {"id": 2, "name": "Bob"},
        {"id": 3, "name": "Charlie"}
    ]`

    // JSONデータを読み込むためのDecoderを作成
    decoder := json.NewDecoder(os.Stdin)

    // JSON配列の開始をチェック
    _, err := decoder.Token()
    if err != nil {
        panic(err)
    }

    // 配列の要素を逐次的にデコード
    for decoder.More() {
        var record Record
        err := decoder.Decode(&record)
        if err != nil {
            panic(err)
        }
        fmt.Printf("Decoded Record: %+v\n", record)
    }

    // JSON配列の終了をチェック
    _, err = decoder.Token()
    if err != nil {
        panic(err)
    }
}

このコードの動作説明

  1. json.NewDecoder: ストリーム(例: ファイル、標準入力)からデータを取得するためにデコーダを初期化します。
  2. 配列のトークンチェック: JSON配列の開始([)と終了(])をトークンとして確認します。
  3. decoder.More(): JSON配列に未処理の要素がある場合にtrueを返します。
  4. decoder.Decode: 配列内の個々の要素をGoの構造体にデコードします。

逐次処理の注意点

  1. エラーハンドリング: JSONデータの不正な形式に注意し、適切なエラーチェックを行うことが重要です。
  2. ストリームの終了: ストリームが途中で終了する場合に備えて、エラー処理を設計する必要があります。

Go言語のjson.Decoderは、大規模データを扱う際に極めて有効なツールです。次章では、実際に大規模データを処理する具体的な例を見ていきます。

大規模データにおける具体例

Go言語を用いた大規模JSONデータの逐次処理は、APIレスポンスやログファイルの処理など、実際のプロジェクトでよく活用されます。ここでは、サンプルデータを使って、逐次処理によるメモリ効率の高いデータ操作の具体例を紹介します。

大規模JSONデータの処理例


以下は、JSON形式のログファイルを逐次処理して、特定の条件に一致するデータを抽出するプログラムの例です。

サンプルコード:条件付きデータ抽出

package main

import (
    "encoding/json"
    "fmt"
    "os"
)

type LogEntry struct {
    Timestamp string `json:"timestamp"`
    Level     string `json:"level"`
    Message   string `json:"message"`
}

func main() {
    // 大規模なJSONログファイルを想定
    file, err := os.Open("large_logs.json")
    if err != nil {
        panic(err)
    }
    defer file.Close()

    // Decoderの作成
    decoder := json.NewDecoder(file)

    // 配列の開始トークンを確認
    _, err = decoder.Token()
    if err != nil {
        panic(err)
    }

    // 条件に一致するログを処理
    for decoder.More() {
        var entry LogEntry
        err := decoder.Decode(&entry)
        if err != nil {
            panic(err)
        }

        // 条件: エラーレベルのログのみ出力
        if entry.Level == "ERROR" {
            fmt.Printf("Error Log: %+v\n", entry)
        }
    }

    // 配列の終了トークンを確認
    _, err = decoder.Token()
    if err != nil {
        panic(err)
    }
}

コードの詳細解説

  1. os.Open: 大規模なJSONファイルをストリームとして開きます。これにより、一括読み込みを避け、メモリ消費を抑えます。
  2. json.NewDecoder: ファイルストリームからJSONデータを逐次デコードするデコーダを作成します。
  3. 条件付き処理: entry.Level == "ERROR"のように条件を設定し、必要なデータのみを抽出します。
  4. リソース管理: defer file.Close()で、ファイルを閉じてリソースを解放します。

大規模データ処理における利点

  • 効率的なメモリ使用: 必要なデータのみを逐次的にメモリにロードするため、メモリ不足のリスクを軽減します。
  • スケーラビリティ: JSONデータが増加しても、システムのリソースに大きな影響を与えずに処理可能です。
  • 柔軟なデータ操作: 条件に基づいたフィルタリングや集計が容易に実現できます。

応用例

  • ログ解析: サーバーログやアプリケーションログからエラーデータや重要なイベントを抽出。
  • APIレスポンスの部分処理: ページングされたAPIから大量のデータを逐次処理して、必要な情報を収集。
  • データストリーム処理: IoTセンサーから送られるJSONデータをリアルタイムで解析。

このような具体例を通じて、大規模なJSONデータを効率的に処理する方法を学び、実践で役立てることができます。次章では、メモリ消費の分析と最適化について詳しく説明します。

メモリ消費の分析と最適化手法

大規模なJSONデータを処理する際、メモリ使用量を適切に管理することは非常に重要です。Go言語では、プロファイリングツールを活用してメモリ消費を分析し、効率化を図ることが可能です。ここでは、メモリ消費の可視化方法と最適化の具体的な手法について解説します。

メモリ消費のプロファイリング


Goには、メモリ使用状況を分析するためのツールとしてpprofが用意されています。以下は、プロファイリングの基本的な手順です。

コードにプロファイリングを追加

package main

import (
    "encoding/json"
    "fmt"
    "os"
    "runtime/pprof"
)

type Record struct {
    ID    int    `json:"id"`
    Value string `json:"value"`
}

func main() {
    // プロファイルの開始
    f, err := os.Create("mem.prof")
    if err != nil {
        panic(err)
    }
    defer f.Close()
    defer pprof.WriteHeapProfile(f)

    // サンプルデータを逐次処理
    file, err := os.Open("large_data.json")
    if err != nil {
        panic(err)
    }
    defer file.Close()

    decoder := json.NewDecoder(file)
    for decoder.More() {
        var record Record
        err := decoder.Decode(&record)
        if err != nil {
            panic(err)
        }
        fmt.Println(record)
    }
}

プロファイルの分析

  1. コードを実行すると、mem.profというファイルが生成されます。
  2. 以下のコマンドを使用して、メモリ使用状況を可視化します:
   go tool pprof -http=:8080 mem.prof
  1. ブラウザが開き、メモリ使用量のヒートマップや詳細が表示されます。

メモリ使用量の最適化手法


プロファイリングで得られた情報を基に、以下のような手法でメモリ消費を最適化します。

1. 不要なデータ構造の削減


JSONデコード時に必要なフィールドだけを含む構造体を使用し、不要なメモリ割り当てを避けます。
例:

type CompactRecord struct {
    ID int `json:"id"`
}

2. 再利用可能なバッファの使用


大規模データの処理では、バッファを再利用することでメモリ消費を削減できます。
例:

var record Record
for decoder.More() {
    err := decoder.Decode(&record)
    if err != nil {
        panic(err)
    }
    fmt.Println(record)
}

3. 並列処理の活用


データを小さなチャンクに分割し、複数のゴルーチンで並列処理することで効率化を図ります。

具体例:最適化後の処理


以下は、メモリ効率を改善した逐次処理の例です:

func processRecords(file *os.File) {
    decoder := json.NewDecoder(file)
    var record Record
    for decoder.More() {
        err := decoder.Decode(&record)
        if err != nil {
            panic(err)
        }
        // 必要なデータのみ処理
        if record.ID%2 == 0 {
            fmt.Println("Even Record:", record)
        }
    }
}

最適化の成果

  • メモリ消費の削減: プロファイリングで確認できるように、使用メモリを最小化できます。
  • 処理速度の向上: メモリ割り当てが減少し、パフォーマンスが改善します。
  • システム安定性の向上: メモリ不足によるクラッシュやパフォーマンス低下を防止できます。

メモリ消費の分析と最適化を行うことで、大規模データ処理の効率を飛躍的に向上させることが可能です。次章では、エラーハンドリングの重要性とその具体的な方法を解説します。

エラーハンドリングとロバスト性の向上

大規模なJSONデータを逐次処理する際、エラーの適切な処理はアプリケーションのロバスト性を保つ上で不可欠です。エラーハンドリングが不十分だと、データの破損や処理の中断、システム全体のクラッシュにつながる可能性があります。本章では、Go言語での効果的なエラーハンドリングとその実装方法について解説します。

逐次処理で発生する可能性のあるエラー

  1. デコードエラー: JSONデータの形式が期待と異なる場合に発生します(例: 不正な構造やデータ型の不一致)。
  2. ストリームエラー: ネットワークやファイルからのデータ読み込み中に発生するエラーです。
  3. 処理エラー: データ検証やビジネスロジックの適用中に発生するエラーです。

エラーハンドリングの基本的な手法


Goでは、エラーが発生するたびにerror型を返す関数設計が基本です。これを活用して、エラーを逐一チェックし適切な処理を行います。

サンプルコード:エラー処理を組み込んだ逐次処理

package main

import (
    "encoding/json"
    "fmt"
    "os"
)

type Record struct {
    ID    int    `json:"id"`
    Value string `json:"value"`
}

func main() {
    file, err := os.Open("large_data.json")
    if err != nil {
        fmt.Printf("Failed to open file: %v\n", err)
        return
    }
    defer file.Close()

    decoder := json.NewDecoder(file)

    for decoder.More() {
        var record Record
        err := decoder.Decode(&record)
        if err != nil {
            fmt.Printf("Decoding error: %v\n", err)
            continue // エラーが発生しても次のデータに進む
        }

        // データ処理
        if record.ID%2 == 0 {
            fmt.Printf("Processed Record: %+v\n", record)
        }
    }
}

このコードのエラー処理ポイント

  1. ファイルオープン時のエラー: ファイルが存在しない、または権限が不足している場合にエラーを適切にログ出力し、処理を中断します。
  2. デコードエラー時のスキップ: JSONデコード中にエラーが発生した場合、そのレコードをスキップして次のデータの処理を続行します。
  3. エラー内容のログ出力: 発生したエラーを標準エラー出力に記録して、後でトラブルシューティングが可能になるようにします。

ロバスト性を高める応用的な手法

1. 詳細なエラー分類


エラー内容を分類し、適切な処理を行います。Goのerrors.Iserrors.Asを活用して、エラーの種類に応じた処理を実現します。

if errors.Is(err, io.EOF) {
    fmt.Println("Reached end of file")
} else {
    fmt.Printf("Unexpected error: %v\n", err)
}

2. リトライ処理


ネットワークエラーや一時的なストリームエラーに対して、リトライ処理を導入します。

3. エラーデータの記録


エラーが発生したデータをログファイルや別の出力先に記録して、後続の調査や再処理を可能にします。

logFile, _ := os.Create("error_logs.txt")
defer logFile.Close()
fmt.Fprintf(logFile, "Failed Record: %v\n", record)

エラーハンドリングの成果

  1. 処理の継続性: エラーが発生しても、全体の処理が中断されることなく進行します。
  2. トラブルシューティングの容易さ: エラーの原因を特定しやすくなり、迅速な対応が可能です。
  3. ユーザー体験の向上: 安定したデータ処理が可能になり、信頼性の高いシステム運用を実現します。

次章では、JSONデータの逐次処理の応用例として、リアルタイムログストリームの処理方法を紹介します。

応用例:ログデータのストリーム処理

JSON形式のログデータをリアルタイムでストリーム処理することは、多くのアプリケーションで必要とされるタスクです。特に、分散システムやマイクロサービス環境では、大量のログデータを迅速かつ効率的に処理する仕組みが求められます。ここでは、Go言語を用いたログデータのストリーム処理の実例を紹介します。

ストリーム処理の特性と利点

  1. リアルタイム性: ログデータが生成され次第、即座に処理できます。
  2. メモリ効率: データを一括でメモリにロードするのではなく、ストリームとして処理するためメモリ使用量が最小化されます。
  3. スケーラビリティ: ストリーム処理の並列化により、大量のログデータを効率的に処理できます。

サンプルコード:JSONログの逐次ストリーム処理

以下は、リアルタイムでログデータを解析し、エラーレベルのログを抽出して出力するコード例です。

package main

import (
    "bufio"
    "encoding/json"
    "fmt"
    "os"
    "strings"
)

type LogEntry struct {
    Timestamp string `json:"timestamp"`
    Level     string `json:"level"`
    Message   string `json:"message"`
}

func main() {
    // 標準入力からログを読み取る(リアルタイムストリームを想定)
    scanner := bufio.NewScanner(os.Stdin)

    fmt.Println("Start processing logs...")

    for scanner.Scan() {
        // JSONログを取得
        logLine := scanner.Text()

        // 空行や無効なデータのスキップ
        if strings.TrimSpace(logLine) == "" {
            continue
        }

        // JSONログをデコード
        var entry LogEntry
        err := json.Unmarshal([]byte(logLine), &entry)
        if err != nil {
            fmt.Printf("Invalid log entry: %s, error: %v\n", logLine, err)
            continue
        }

        // 条件付き処理:エラーログのみ出力
        if entry.Level == "ERROR" {
            fmt.Printf("ERROR detected: [%s] %s\n", entry.Timestamp, entry.Message)
        }
    }

    // エラー処理
    if err := scanner.Err(); err != nil {
        fmt.Printf("Error reading logs: %v\n", err)
    }
}

コードの動作解説

  1. bufio.Scannerの使用: 標準入力(またはファイルストリーム)からリアルタイムでデータを1行ずつ読み取ります。
  2. JSONデコード: 取得したログ行をjson.Unmarshalでデコードし、Goの構造体にマッピングします。
  3. 条件付き処理: ログのLevelERRORの場合にのみログメッセージを出力します。
  4. エラー処理: 無効なJSONデータはスキップし、適切なエラー内容をログに記録します。

この手法の応用可能性

  • リアルタイム監視: サーバーログやセンサーデータの異常検知。
  • ログフィルタリング: 特定の条件に一致するログを別のストリームやファイルに保存。
  • アラートシステムの統合: エラーログを即時検出し、通知や自動復旧処理を実行。

ストリーム処理の最適化のポイント

  1. 並列処理の導入: ログデータを複数のワーカーゴルーチンで処理し、パフォーマンスを向上させます。
  2. ログ出力の抑制: 必要なデータのみを記録し、出力先を最適化します(例: ファイル、データベース、メッセージキュー)。
  3. エラーハンドリングの強化: 異常データや一時的なストリームエラーに対応する仕組みを実装します。

実行例


以下のようなJSONログをリアルタイムで標準入力から渡すことで、エラーログだけがフィルタリングされます:

{"timestamp": "2024-11-18T12:00:00Z", "level": "INFO", "message": "Service started."}
{"timestamp": "2024-11-18T12:01:00Z", "level": "ERROR", "message": "Failed to connect to database."}
{"timestamp": "2024-11-18T12:02:00Z", "level": "WARN", "message": "High memory usage detected."}

出力例:

ERROR detected: [2024-11-18T12:01:00Z] Failed to connect to database.

このように、Go言語のストリーム処理は、リアルタイムデータの効率的な処理に非常に適しており、様々なシステムに応用できます。次章では、さらに効率を高めるためにJSON処理と並列処理を組み合わせた手法を解説します。

JSONと並列処理の組み合わせ

Go言語の特徴であるゴルーチンとチャネルを活用することで、JSONデータの処理効率をさらに高めることができます。特に、大量のJSONデータをリアルタイムで処理する際に、並列処理を導入することで処理速度の向上が期待できます。本章では、JSONの逐次処理に並列処理を組み合わせる方法と、その実装例を紹介します。

並列処理の利点

  1. 処理速度の向上: 複数のゴルーチンでデータを並行処理することで、総処理時間を短縮できます。
  2. 負荷分散: データを分割し、複数のワーカーで処理することでCPUの効率的な利用が可能になります。
  3. スケーラビリティ: 増加するデータ量に対して柔軟に対応できます。

サンプルコード:JSONデータの並列処理

以下は、JSONログデータを並列処理でフィルタリングする例です。

package main

import (
    "encoding/json"
    "fmt"
    "os"
    "sync"
)

type LogEntry struct {
    Timestamp string `json:"timestamp"`
    Level     string `json:"level"`
    Message   string `json:"message"`
}

func main() {
    // ファイルストリームを開く
    file, err := os.Open("large_logs.json")
    if err != nil {
        panic(err)
    }
    defer file.Close()

    decoder := json.NewDecoder(file)

    // ワーカーゴルーチンの数
    const numWorkers = 4

    // チャネルを用意
    dataChan := make(chan LogEntry)
    doneChan := make(chan struct{})

    // ワーカーの起動
    var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go worker(i, dataChan, doneChan, &wg)
    }

    // データをチャネルに送信
    go func() {
        defer close(dataChan)
        for decoder.More() {
            var entry LogEntry
            err := decoder.Decode(&entry)
            if err != nil {
                fmt.Printf("Decoding error: %v\n", err)
                continue
            }
            dataChan <- entry
        }
    }()

    // ワーカーの終了を待つ
    wg.Wait()
    close(doneChan)
    fmt.Println("Processing completed.")
}

// ワーカー関数
func worker(id int, dataChan <-chan LogEntry, doneChan chan<- struct{}, wg *sync.WaitGroup) {
    defer wg.Done()
    for entry := range dataChan {
        // 条件: エラーレベルのログを処理
        if entry.Level == "ERROR" {
            fmt.Printf("Worker %d processed ERROR: [%s] %s\n", id, entry.Timestamp, entry.Message)
        }
    }
}

コードの動作解説

  1. チャネルの利用: dataChanを通じて、デコードしたログデータをワーカーに送信します。
  2. ワーカーの並列処理: 複数のゴルーチン(ワーカー)でデータを並列処理します。各ワーカーはdataChanからデータを受け取り、条件に基づいて処理します。
  3. 同期処理: sync.WaitGroupを使用して、全てのワーカーの処理が終了するのを待機します。
  4. エラー処理: JSONデコード中に発生したエラーを適切に処理し、無効なデータをスキップします。

並列処理の応用例

  • 分散システム: 大量のログデータやセンサーデータを分割し、各ワーカーで並列処理。
  • ETLパイプライン: データを抽出、変換、ロードするプロセスでの効率化。
  • リアルタイムアラートシステム: エラーログを即時検出し、アラートを生成。

実行例


以下のようなJSONログファイルを並列処理します:

{"timestamp": "2024-11-18T12:00:00Z", "level": "INFO", "message": "Service started."}
{"timestamp": "2024-11-18T12:01:00Z", "level": "ERROR", "message": "Database connection failed."}
{"timestamp": "2024-11-18T12:02:00Z", "level": "ERROR", "message": "Memory usage exceeded limit."}
{"timestamp": "2024-11-18T12:03:00Z", "level": "INFO", "message": "Scheduled task completed."}

出力例:

Worker 0 processed ERROR: [2024-11-18T12:01:00Z] Database connection failed.
Worker 2 processed ERROR: [2024-11-18T12:02:00Z] Memory usage exceeded limit.

並列処理のメリットと注意点

  1. メリット: 処理速度が大幅に向上し、リアルタイム性が確保されます。
  2. 注意点: ワーカー間でのリソース競合や、処理順序の制御が必要な場合には注意が必要です。

次章では、ここまで解説した内容を振り返り、Go言語でのメモリ効率的なJSON逐次処理のポイントをまとめます。

まとめ

本記事では、Go言語を使用したメモリ効率的なJSONデータの逐次処理について解説しました。基本的なJSON処理から始まり、json.Decoderを用いた逐次処理の仕組み、大規模データへの適用例、メモリ消費の分析と最適化、エラーハンドリング、ストリーム処理の応用、そして並列処理の導入まで、幅広く紹介しました。

特に、以下のポイントが重要です:

  • 逐次処理の利点: メモリ消費を抑え、大規模データを効率的に処理できる。
  • エラーハンドリングの強化: 不正データや一時的なエラーに柔軟に対応可能。
  • 並列処理の活用: 処理速度を大幅に向上し、リアルタイム性を確保。

Go言語の特性を最大限に活かすことで、スケーラブルで効率的なデータ処理が可能になります。この知識を応用し、現実のプロジェクトに適用することで、安定性とパフォーマンスの向上を実現してください。

コメント

コメントする

目次