Go 言語によるキャッシュコンポーネントの開発

GolangGolangBeginner
今すぐ練習

💡 このチュートリアルは英語版からAIによって翻訳されています。原文を確認するには、 ここをクリックしてください

はじめに

このプロジェクトでは、キャッシュの原則と重要性について学び、その後、Go言語を使ってキャッシュコンポーネントを設計・実装します。

キャッシュは、コンピュータシステムで広く使われる技術であり、頻繁にアクセスされるデータをメモリに保存することでパフォーマンスを向上させます。これにより、高速な検索が可能になり、データベースやリモートサービスなどの低速なデータソースへのアクセスが必要なくなります。

このプロジェクトでは、キャッシュの原則と利点について学びます。また、Go言語を使ってキャッシュコンポーネントを設計・実装します。キャッシュコンポーネントには、キャッシュデータの保存、期限切れのデータアイテムの管理、データのインポート・エクスポート、およびCRUD(作成、読み取り、更新、削除)操作などの機能があります。

このプロジェクトを完了することで、キャッシュの原則、データ構造、およびGoプログラミングに関する知識とスキルを身につけることができます。これにより、キャッシュ技術を効果的に活用した効率的で高性能なソフトウェアシステムを構築することができます。

🎯 タスク

このプロジェクトでは、以下のことを学びます。

  • キャッシュの原則と重要性を理解する方法
  • メモリにデータを保存し管理するためのキャッシュシステムを設計する方法
  • キャッシュシステムのCRUD操作と期限管理を実装する方法
  • キャッシュシステムからのデータのインポート・エクスポート機能を追加する方法

🏆 成果

このプロジェクトを完了すると、以下のことができるようになります。

  • キャッシュの原則と利点を説明する
  • 健全な設計原則に基づいたキャッシュシステムを設計する
  • キャッシュ管理のための効率的なデータ構造とアルゴリズムを実装する
  • GoでキャッシュシステムのCRUD操作を開発する
  • インポート・エクスポート操作のためにデータをシリアライズおよび逆シリアライズする

キャッシュとは?

キャッシュはコンピュータハードウェアに一般的に見られます。たとえば、CPUには1級キャッシュ、2級キャッシュ、さらには3級キャッシュがあります。キャッシュの動作原理は、CPUがデータを読み取る必要があるとき、まずキャッシュ内で必要なデータを検索します。見つかれば、それを直接処理します。見つからなければ、データをメモリから読み取ります。CPU内のキャッシュはメモリよりも高速であるため、キャッシュを使用することでCPUの処理速度を加速できます。キャッシュはハードウェアだけでなく、さまざまなソフトウェアシステムにも存在します。たとえば、Webシステムでは、サーバー、クライアント、またはプロキシサーバーにキャッシュが存在します。広く使用されているCDN(コンテンツ配信ネットワーク)も巨大なキャッシュシステムと見なすことができます。Webシステムでキャッシュを使用すると、ネットワークトラフィックの削減、クライアントのアクセス遅延の低減、サーバー負荷の軽減など、多くの利点があります。

現在、Memcache、Redisなど、多くの高性能なキャッシュシステムがあります。特にRedisは、現在、さまざまなWebサービスで広く使用されています。このように機能豊富なキャッシュシステムが既にあるのに、なぜ自分でキャッシュシステムを実装する必要があるのでしょうか。これを行う主な理由は2つあります。まず、自分で実装することで、キャッシュシステムの動作原理を理解できるからです。これは古典的な理由です。第二に、Redisのようなキャッシュシステムは独立して存在します。単純なアプリケーションを開発するだけの場合は、別のRedisサーバーを使用するのは過度に複雑になる場合があります。この場合、これらの機能を実装した機能豊富なソフトウェアパッケージがあれば最適です。このソフトウェアパッケージを単にインポートするだけで、別のRedisサーバーが必要なくキャッシュ機能を実現できます。

キャッシュシステムの設計

キャッシュシステムでは、キャッシュされたデータは通常メモリに保存されます。したがって、私たちが設計するキャッシュシステムは、メモリ内のデータをある方法で管理する必要があります。システムがシャットダウンすると、データが失われないでしょうか?実際、ほとんどの場合、キャッシュシステムはまた、メモリ内のデータをファイルに書き込むこともサポートしています。システムが再起動するとき、ファイル内のデータを再びメモリに読み込むことができます。このようにして、システムがシャットダウンしても、キャッシュデータは失われません。

同時に、キャッシュシステムはまた、期限切れのデータをクリーンアップするメカニズムを提供します。これは、キャッシュ内の各データ項目には寿命があることを意味します。データ項目が期限切れになると、メモリから削除されます。その結果、ホットデータは常に利用可能であり、コールドデータはキャッシュする必要がないため削除されます。

キャッシュシステムはまた、外部操作のためのインターフェイスを提供する必要があり、システムの他のコンポーネントがキャッシュを利用できるようにします。通常、キャッシュシステムはCRUD操作をサポートする必要があり、これには作成(追加)、読み取り、更新、および削除が含まれます。

以上の分析に基づいて、キャッシュシステムは以下の機能を備える必要があることをまとめることができます。

  • キャッシュデータの保存
  • 期限切れのデータ項目の管理
  • メモリからのデータのインポートとエクスポート
  • CRUDインターフェイスの提供。

開発準備

まず、作業ディレクトリを作成して GOPATH 環境変数を設定します。

cd ~/project/
mkdir -p golang/src
export GOPATH=~/project/golang

上記の手順では、~/project/golang ディレクトリを作成し、それを後続の実験用の GOPATH として設定しています。

キャッシュシステムの基本構造

キャッシュされたデータは、高速にアクセスするためにメモリに保存する必要があります。データ項目を保存するためにどのデータ構造を使用するか。一般的に、データ項目を保存するためにハッシュテーブルが使用されます。なぜなら、これはデータへのアクセスに対してより良いパフォーマンスを提供するからです。Go言語では、独自のハッシュテーブルを実装する必要はありません。なぜなら、組み込み型 map が既にハッシュテーブルを実装しているからです。したがって、キャッシュデータ項目を直接 map に保存することができます。

キャッシュシステムはまた、期限切れのデータをクリーンアップすることもサポートしているため、キャッシュデータ項目には寿命が必要です。これは、キャッシュデータ項目をキャッシュシステムにカプセル化して保存する必要があることを意味します。これを行うには、まずキャッシュデータ項目を実装する必要があります。GOPATH/src ディレクトリに新しいディレクトリ cache を作成し、ソースファイル cache.go を作成します。

package cache

import (
    "encoding/gob"
    "fmt"
    "io"
    "os"
    "sync"
    "time"
)

type Item struct {
    Object     interface{} // 実際のデータ項目
    Expiration int64       // 寿命
}

// データ項目が期限切れになったかどうかを確認する
func (item Item) Expired() bool {
    if item.Expiration == 0 {
        return false
    }
    return time.Now().UnixNano() > item.Expiration
}

上記のコードでは、2つのフィールドを持つ Item 構造体を定義しています。Object は任意の型のデータオブジェクトを保存するために使用され、Expiration はデータ項目の期限切れ時間を保存します。また、Item 型に Expired() メソッドを提供しており、これはデータ項目が期限切れになったかどうかを示すブール値を返します。重要なことは、データ項目の期限切れ時間はナノ秒で測定されるUnixタイムスタンプであることです。どのようにしてデータ項目が期限切れになったかを判断するのか。実はとても簡単です。各データ項目の期限切れ時間を記録し、キャッシュシステムが定期的に各データ項目をチェックします。データ項目の期限切れ時間が現在の時間よりも早い場合、そのデータ項目はキャッシュシステムから削除されます。これを行うには、time モジュールを使用して定期的なタスクを実装します。

これで、キャッシュシステムのフレームワークを実装することができます。コードは以下の通りです。

const (
    // 期限切れ時間なしのフラグ
    NoExpiration time.Duration = -1

    // デフォルトの期限切れ時間
    DefaultExpiration time.Duration = 0
)

type Cache struct {
    defaultExpiration time.Duration
    items             map[string]Item // キャッシュデータ項目をmapに保存
    mu                sync.RWMutex    // 読み書きロック
    gcInterval        time.Duration   // 期限切れデータのクリーンアップ間隔
    stopGc            chan bool
}

// 期限切れのキャッシュデータ項目をクリーンアップする
func (c *Cache) gcLoop() {
    ticker := time.NewTicker(c.gcInterval)
    for {
        select {
        case <-ticker.C:
            c.DeleteExpired()
        case <-c.stopGc:
            ticker.Stop()
            return
        }
    }
}

上記のコードでは、キャッシュシステム構造を表す Cache 構造体を実装しています。items フィールドは、キャッシュデータ項目を保存するための map です。ご覧の通り、gcLoop() メソッドも実装しており、これは time.Ticker を使用して DeleteExpired() メソッドを定期的に実行するようにスケジュールしています。time.NewTicker() を使用して作成された ticker は、指定された gcInterval 間隔でその ticker.C チャネルからデータを送信します。この特性を利用して、定期的に DeleteExpired() メソッドを実行することができます。

gcLoop() 関数が正常に終了できるようにするために、c.stopGc チャネルからのデータを待ち受けます。このチャネルにデータが送信された場合、gcLoop() の実行を停止します。また、NoExpirationDefaultExpiration 定数を定義しています。前者は期限切れにならないデータ項目を表し、後者はデフォルトの期限切れ時間を持つデータ項目を表します。どのように DeleteExpired() を実装するか。以下のコードを参照してください。

// キャッシュデータ項目を削除する
func (c *Cache) delete(k string) {
    delete(c.items, k)
}

// 期限切れのデータ項目を削除する
func (c *Cache) DeleteExpired() {
    now := time.Now().UnixNano()
    c.mu.Lock()
    defer c.mu.Unlock()

    for k, v := range c.items {
        if v.Expiration > 0 && now > v.Expiration {
            c.delete(k)
        }
    }
}

ご覧の通り、DeleteExpired() メソッドは非常に簡単です。すべてのデータ項目を反復処理し、期限切れのものを削除するだけです。

✨ 解答を確認して練習

キャッシュシステムのCRUDインターフェイスの実装

ここで、キャッシュシステムのCRUDインターフェイスを実装することができます。以下のインターフェイスを使用して、キャッシュシステムにデータを追加することができます。

// キャッシュデータ項目を設定し、既に存在する場合は上書きする
func (c *Cache) Set(k string, v interface{}, d time.Duration) {
    var e int64
    if d == DefaultExpiration {
        d = c.defaultExpiration
    }
    if d > 0 {
        e = time.Now().Add(d).UnixNano()
    }
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[k] = Item{
        Object:     v,
        Expiration: e,
    }
}

// ロック操作なしでデータ項目を設定する
func (c *Cache) set(k string, v interface{}, d time.Duration) {
    var e int64
    if d == DefaultExpiration {
        d = c.defaultExpiration
    }
    if d > 0 {
        e = time.Now().Add(d).UnixNano()
    }
    c.items[k] = Item{
        Object:     v,
        Expiration: e,
    }
}

// データ項目を取得する。また、項目が期限切れになったかどうかもチェックする
func (c *Cache) get(k string) (interface{}, bool) {
    item, found := c.items[k]
    if!found {
        return nil, false
    }
    if item.Expired() {
        return nil, false
    }
    return item.Object, true
}

// データ項目を追加する。既に存在する場合はエラーを返す
func (c *Cache) Add(k string, v interface{}, d time.Duration) error {
    c.mu.Lock()
    _, found := c.get(k)
    if found {
        c.mu.Unlock()
        return fmt.Errorf("Item %s already exists", k)
    }
    c.set(k, v, d)
    c.mu.Unlock()
    return nil
}

// データ項目を取得する
func (c *Cache) Get(k string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()

    item, found := c.items[k]
    if!found {
        return nil, false
    }
    if item.Expired() {
        return nil, false
    }
    return item.Object, true
}

上記のコードでは、Set()Add() インターフェイスを実装しています。両者の主な違いは、前者は既に存在する場合にキャッシュシステム内のデータ項目を上書きする一方、後者は既にデータ項目が存在する場合にエラーを投げ、キャッシュが誤って上書きされるのを防ぐことです。また、Get() メソッドも実装しており、これはキャッシュシステムからデータ項目を取得します。重要なことは、キャッシュデータ項目が存在するという真の意味は、項目が存在しており、期限切れになっていないことです。

次に、削除と更新インターフェイスを実装することができます。

// 既存のデータ項目を置き換える
func (c *Cache) Replace(k string, v interface{}, d time.Duration) error {
    c.mu.Lock()
    _, found := c.get(k)
    if!found {
        c.mu.Unlock()
        return fmt.Errorf("Item %s doesn't exist", k)
    }
    c.set(k, v, d)
    c.mu.Unlock()
    return nil
}

// データ項目を削除する
func (c *Cache) Delete(k string) {
    c.mu.Lock()
    c.delete(k)
    c.mu.Unlock()
}

上記のコードは自明であるため、詳細には触れません。

✨ 解答を確認して練習

キャッシュシステムのインポートとエクスポート

以前、キャッシュシステムがファイルにデータをインポートし、ファイルからデータを読み込むことをサポートしていることを述べました。今、この機能を実装しましょう。

// キャッシュデータ項目をio.Writerに書き込む
func (c *Cache) Save(w io.Writer) (err error) {
    enc := gob.NewEncoder(w)
    defer func() {
        if x := recover(); x!= nil {
            err = fmt.Errorf("Error registering item types with Gob library")
        }
    }()
    c.mu.RLock()
    defer c.mu.RUnlock()
    for _, v := range c.items {
        gob.Register(v.Object)
    }
    err = enc.Encode(&c.items)
    return
}

// データ項目をファイルに保存する
func (c *Cache) SaveToFile(file string) error {
    f, err := os.Create(file)
    if err!= nil {
        return err
    }
    if err = c.Save(f); err!= nil {
        f.Close()
        return err
    }
    return f.Close()
}

// io.Readerからデータ項目を読み込む
func (c *Cache) Load(r io.Reader) error {
    dec := gob.NewDecoder(r)
    items := map[string]Item{}
    err := dec.Decode(&items)
    if err == nil {
        c.mu.Lock()
        defer c.mu.Unlock()
        for k, v := range items {
            ov, found := c.items[k]
            if!found || ov.Expired() {
                c.items[k] = v
            }
        }
    }
    return err
}

// キャッシュデータ項目をファイルから読み込む
func (c *Cache) LoadFile(file string) error {
    f, err := os.Open(file)
    if err!= nil {
        return err
    }
    if err = c.Load(f); err!= nil {
        f.Close()
        return err
    }
    return f.Close()
}

上記のコードでは、Save() メソッドは gob モジュールを使用してバイナリキャッシュデータをエンコードし、io.Writer インターフェイスを実装するオブジェクトに書き込みます。一方、Load() メソッドは io.Reader からバイナリデータを読み取り、その後 gob モジュールを使用してデータを逆シリアライズします。基本的には、ここではキャッシュデータをシリアライズおよび逆シリアライズしています。

✨ 解答を確認して練習

キャッシュシステムのその他のインターフェイス

これまでで、キャッシュシステムの機能はすべて完了し、大部分の作業が終了しています。最後のタスクをまとめましょう。

// キャッシュされたデータ項目の数を返す
func (c *Cache) Count() int {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return len(c.items)
}

// キャッシュをクリアする
func (c *Cache) Flush() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items = map[string]Item{}
}

// 期限切れのキャッシュのクリーンアップを停止する
func (c *Cache) StopGc() {
    c.stopGc <- true
}

// 新しいキャッシュシステムを作成する
func NewCache(defaultExpiration, gcInterval time.Duration) *Cache {
    c := &Cache{
        defaultExpiration: defaultExpiration,
        gcInterval:        gcInterval,
        items:             map[string]Item{},
        stopGc:            make(chan bool),
    }
    // 期限切れのクリーンアップゴルーチンを開始する
    go c.gcLoop()
    return c
}

上記のコードでは、いくつかのメソッドを追加しています。Count() はシステムにキャッシュされているデータ項目の数を返し、Flush() はキャッシュシステム全体をクリアし、StopGc() はキャッシュシステムが期限切れのデータ項目をクリーンアップするのを停止します。最後に、NewCache() メソッドを使用して新しいキャッシュシステムを作成することができます。

これまでで、キャッシュシステム全体が完成しました。とても簡単ですよね。では、いくつかのテストを行いましょう。

✨ 解答を確認して練習

キャッシュシステムのテスト

サンプルプログラムを書きます。そのソースコードは ~/project/golang/src/cache/sample/sample.go にあります。プログラムの内容は以下の通りです。

package main

import (
    "cache"
    "fmt"
    "time"
)

func main() {
    defaultExpiration, _ := time.ParseDuration("0.5h")
    gcInterval, _ := time.ParseDuration("3s")
    c := cache.NewCache(defaultExpiration, gcInterval)

    k1 := "hello labex"
    expiration, _ := time.ParseDuration("5s")

    c.Set("k1", k1, expiration)
    s, _ := time.ParseDuration("10s")
    if v, found := c.Get("k1"); found {
        fmt.Println("Found k1: ", v)
    } else {
        fmt.Println("Not found k1")
    }
    // 10秒間待機する
    time.Sleep(s)
    // 今ではk1はクリアされているはず
    if v, found := c.Get("k1"); found {
        fmt.Println("Found k1: ", v)
    } else {
        fmt.Println("Not found k1")
    }
}

サンプルコードは非常に簡単です。NewCache メソッドを使ってキャッシュシステムを作成し、データの期限切れクリーンアップ期間を3秒、デフォルトの期限切れ時間を30分に設定します。その後、期限切れ時間が5秒のデータ項目 "k1" を設定します。データ項目を設定した後、直ちに取得し、その後10秒間待機します。再度 "k1" を取得するとき、それはクリアされているはずです。このプログラムは以下のコマンドを使って実行できます。

cd ~/project/golang/src/cache
go mod init
go run sample/sample.go

出力は以下の通りになります。

Found k1: hello labex
Not found k1
✨ 解答を確認して練習

まとめ

このプロジェクトでは、データオブジェクトの追加、削除、置き換え、照会が可能なキャッシュシステムを開発しました。また、期限切れのデータを削除する機能も備えています。Redisに慣れている方は、キーに関連付けられた数値をインクリメントする機能をご存知かもしれません。たとえば、「key」の値が「20」に設定されている場合、Increment インターフェイスにパラメータ「2」を渡すことで、「22」に増やすことができます。この機能を、私たちのキャッシュシステムを使って実装してみませんか?