diff --git a/go.mod b/go.mod index 2a0b5d23..8a79def9 100644 --- a/go.mod +++ b/go.mod @@ -126,6 +126,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/maypok86/otter/v2 v2.3.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/paulmach/orb v0.12.0 // indirect diff --git a/go.sum b/go.sum index 976efd9b..900a42c4 100644 --- a/go.sum +++ b/go.sum @@ -224,6 +224,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w= +github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/internal/apps/admin/cache/routers.go b/internal/apps/admin/cache/routers.go index 3b313ddf..97ca546a 100644 --- a/internal/apps/admin/cache/routers.go +++ b/internal/apps/admin/cache/routers.go @@ -129,9 +129,5 @@ func saveOrUpdateConfig(ctx context.Context, key string, value string) error { } } - // Sync config value in Redis cache - if db.Redis != nil { - _ = db.HSetJSON(ctx, model.SystemConfigRedisHashKey, key, &sc) - } - return nil + return model.InvalidateSystemConfigCache(ctx, key) } diff --git a/internal/apps/admin/system_config/routers.go b/internal/apps/admin/system_config/routers.go index ce08f26e..0de98cb4 100644 --- a/internal/apps/admin/system_config/routers.go +++ b/internal/apps/admin/system_config/routers.go @@ -87,16 +87,16 @@ func CreateSystemConfig(c *gin.Context) { return err } - if err := db.HSetJSON(c.Request.Context(), model.SystemConfigRedisHashKey, req.Key, &config); err != nil { - return err - } - return nil }); err != nil { c.JSON(http.StatusInternalServerError, response.Err(err.Error())) return } + if err := model.InvalidateSystemConfigCache(c.Request.Context(), req.Key); err != nil { + logger.WarnF(c.Request.Context(), "清理系统配置缓存失败: %v", err) + } + if err := model.InvalidateVisibleSystemConfigsCache(c.Request.Context()); err != nil { logger.WarnF(c.Request.Context(), "清理公共配置列表缓存失败: %v", err) } @@ -233,10 +233,6 @@ func UpdateSystemConfig(c *gin.Context) { return err } - if err := db.HSetJSON(c.Request.Context(), model.SystemConfigRedisHashKey, key, &config); err != nil { - return err - } - resolveStorageMigrationTasksOnDirectDriverUpdate( c.Request.Context(), tx, @@ -287,6 +283,10 @@ func resolveStorageMigrationTasksOnDirectDriverUpdate( } func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) { + if err := model.InvalidateSystemConfigCache(ctx, key); err != nil { + logger.WarnF(ctx, "清理系统配置缓存失败: %v", err) + } + if key == model.ConfigKeyStorageConfig { upload.ResetAccessCaches() upload.PublishAccessCacheInvalidation(ctx) diff --git a/internal/apps/admin/system_config/routers_test.go b/internal/apps/admin/system_config/routers_test.go index ce2c85a5..c47bc7de 100644 --- a/internal/apps/admin/system_config/routers_test.go +++ b/internal/apps/admin/system_config/routers_test.go @@ -83,17 +83,25 @@ func TestCreateSystemConfig(t *testing.T) { t.Fatalf("failed to find system config in DB: %v", err) } - // Verify Redis Cache - var redisConfig model.SystemConfig - err = db.HGetJSON(context.Background(), model.SystemConfigRedisHashKey, "custom_key", &redisConfig) - if err != nil { - t.Fatalf("failed to find system config in Redis: %v", err) + // Verify caches are invalidated after create and repopulate on read + _, err = db.Redis.HGet( + context.Background(), + db.PrefixedKey(model.SystemConfigRedisHashKey), + "custom_key", + ).Result() + if err == nil { + t.Fatal("expected redis cache miss immediately after create") } - if redisConfig.Value != "custom_value" { - t.Errorf("CreateSystemConfig(custom_key).Value = %q, want %q", redisConfig.Value, "custom_value") + + var loaded model.SystemConfig + if err := loaded.GetByKey(context.Background(), "custom_key"); err != nil { + t.Fatalf("GetByKey(custom_key) error = %v", err) } - if redisConfig.Visibility != model.ConfigVisibilityVisible { - t.Errorf("CreateSystemConfig(custom_key).Visibility = %d, want %d", redisConfig.Visibility, model.ConfigVisibilityVisible) + if loaded.Value != "custom_value" { + t.Errorf("GetByKey(custom_key).Value = %q, want %q", loaded.Value, "custom_value") + } + if loaded.Visibility != model.ConfigVisibilityVisible { + t.Errorf("GetByKey(custom_key).Visibility = %d, want %d", loaded.Visibility, model.ConfigVisibilityVisible) } }) @@ -234,14 +242,25 @@ func TestUpdateSystemConfig(t *testing.T) { t.Errorf("database values not updated: %+v", cfg) } - // Verify Redis - var redisConfig model.SystemConfig - _ = db.HGetJSON(context.Background(), model.SystemConfigRedisHashKey, model.ConfigKeySiteName, &redisConfig) - if redisConfig.Value != "Super Site Name" { - t.Errorf("redis cache value not updated, got '%s'", redisConfig.Value) + // Verify caches are invalidated after update and repopulate on read + _, err := db.Redis.HGet( + context.Background(), + db.PrefixedKey(model.SystemConfigRedisHashKey), + model.ConfigKeySiteName, + ).Result() + if err == nil { + t.Fatal("expected redis cache miss immediately after update") } - if redisConfig.Visibility != model.ConfigVisibilityHidden { - t.Errorf("redis cache visibility = %d, want %d", redisConfig.Visibility, model.ConfigVisibilityHidden) + + var loaded model.SystemConfig + if err := loaded.GetByKey(context.Background(), model.ConfigKeySiteName); err != nil { + t.Fatalf("GetByKey(site_name) error = %v", err) + } + if loaded.Value != "Super Site Name" { + t.Errorf("GetByKey(site_name).Value = %q, want %q", loaded.Value, "Super Site Name") + } + if loaded.Visibility != model.ConfigVisibilityHidden { + t.Errorf("GetByKey(site_name).Visibility = %d, want %d", loaded.Visibility, model.ConfigVisibilityHidden) } }) diff --git a/internal/apps/config/system_config_cache_test.go b/internal/apps/config/system_config_cache_test.go new file mode 100644 index 00000000..237e4248 --- /dev/null +++ b/internal/apps/config/system_config_cache_test.go @@ -0,0 +1,106 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "context" + "errors" + "testing" + + "github.com/redis/go-redis/v9" + + "github.com/Rain-kl/Wavelet/internal/db" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/testhelper" +) + +func TestSystemConfigRAMCacheServesUntilInvalidated(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + ctx := context.Background() + + model.ResetSystemConfigRAMCacheForTest() + if err := model.InvalidateAllSystemConfigCaches(ctx); err != nil { + t.Fatalf("InvalidateAllSystemConfigCaches() error = %v", err) + } + + var warm model.SystemConfig + if err := warm.GetByKey(ctx, model.ConfigKeySiteName); err != nil { + t.Fatalf("GetByKey(site_name) warm error = %v", err) + } + if warm.Value != "Wavelet" { + t.Fatalf("GetByKey(site_name).Value = %q, want %q", warm.Value, "Wavelet") + } + + if err := dbConn.Model(&model.SystemConfig{}). + Where("key = ?", model.ConfigKeySiteName). + Update("value", "ram_probe_value").Error; err != nil { + t.Fatalf("Update(site_name) error = %v", err) + } + if err := db.HDel(ctx, model.SystemConfigRedisHashKey, model.ConfigKeySiteName); err != nil { + t.Fatalf("HDel(site_name) error = %v", err) + } + + var cached model.SystemConfig + if err := cached.GetByKey(ctx, model.ConfigKeySiteName); err != nil { + t.Fatalf("GetByKey(site_name) cached error = %v", err) + } + if cached.Value != "Wavelet" { + t.Fatalf("GetByKey(site_name).Value = %q, want stale RAM value %q", cached.Value, "Wavelet") + } + + if err := model.InvalidateSystemConfigCache(ctx, model.ConfigKeySiteName); err != nil { + t.Fatalf("InvalidateSystemConfigCache(site_name) error = %v", err) + } + + var refreshed model.SystemConfig + if err := refreshed.GetByKey(ctx, model.ConfigKeySiteName); err != nil { + t.Fatalf("GetByKey(site_name) refreshed error = %v", err) + } + if refreshed.Value != "ram_probe_value" { + t.Fatalf("GetByKey(site_name).Value = %q, want %q", refreshed.Value, "ram_probe_value") + } + + exists, err := db.Redis.HExists(ctx, db.PrefixedKey(model.SystemConfigRedisHashKey), model.ConfigKeySiteName).Result() + if err != nil { + t.Fatalf("HExists(site_name) error = %v", err) + } + if !exists { + t.Fatal("expected redis hash field to be repopulated after refresh") + } +} + +func TestInvalidateSystemConfigCacheClearsRedisField(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + ctx := context.Background() + + var sc model.SystemConfig + if err := sc.GetByKey(ctx, model.ConfigKeySiteName); err != nil { + t.Fatalf("GetByKey(site_name) error = %v", err) + } + + if err := model.InvalidateSystemConfigCache(ctx, model.ConfigKeySiteName); err != nil { + t.Fatalf("InvalidateSystemConfigCache(site_name) error = %v", err) + } + + _, err := db.Redis.HGet(ctx, db.PrefixedKey(model.SystemConfigRedisHashKey), model.ConfigKeySiteName).Result() + if !errors.Is(err, redis.Nil) { + t.Fatalf("HGet(site_name) error = %v, want redis.Nil", err) + } + + if err := dbConn.Model(&model.SystemConfig{}). + Where("key = ?", model.ConfigKeySiteName). + Update("value", "after_invalidate").Error; err != nil { + t.Fatalf("Update(site_name) error = %v", err) + } + + var refreshed model.SystemConfig + if err := refreshed.GetByKey(ctx, model.ConfigKeySiteName); err != nil { + t.Fatalf("GetByKey(site_name) refreshed error = %v", err) + } + if refreshed.Value != "after_invalidate" { + t.Fatalf("GetByKey(site_name).Value = %q, want %q", refreshed.Value, "after_invalidate") + } +} \ No newline at end of file diff --git a/internal/db/errs.go b/internal/db/errs.go index 8d8e3f61..ade23152 100644 --- a/internal/db/errs.go +++ b/internal/db/errs.go @@ -4,7 +4,8 @@ package db const ( - errRedisHashSetFailed = "failed to set redis hash: %w" + errRedisHashSetFailed = "failed to set redis hash: %w" + errRedisHashDeleteFailed = "failed to delete redis hash field: %w" errUnmarshalDataFailed = "failed to unmarshal data: %w" errMarshalDataFailed = "failed to marshal data: %w" errRedisKeySetFailed = "failed to set redis key: %w" diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index 48244e99..74e37184 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -69,10 +69,7 @@ func Migrate() { } func clearSystemConfigCache() { - if db.Redis == nil { - return - } - if err := db.Redis.Del(context.Background(), db.PrefixedKey(model.SystemConfigRedisHashKey)).Err(); err != nil { + if err := model.InvalidateAllSystemConfigCaches(context.Background()); err != nil { log.Printf("[%s] clear system config cache failed: %v\n", dbType(), err) } } diff --git a/internal/db/redis.go b/internal/db/redis.go index f2982d36..02c0a98d 100644 --- a/internal/db/redis.go +++ b/internal/db/redis.go @@ -124,6 +124,17 @@ func HSetJSON[T any](ctx context.Context, hashKey, fieldKey string, data T) erro return nil } +// HDel removes one or more fields from a Redis Hash. +func HDel(ctx context.Context, hashKey string, fieldKeys ...string) error { + if Redis == nil || len(fieldKeys) == 0 { + return nil + } + if err := Redis.HDel(ctx, PrefixedKey(hashKey), fieldKeys...).Err(); err != nil { + return fmt.Errorf(errRedisHashDeleteFailed, err) + } + return nil +} + // HGetJSON 从 Redis Hash 获取数据并反序列化为泛型类型 // ctx: 上下文 // hashKey: Redis Hash key diff --git a/internal/model/system_config_cache.go b/internal/model/system_config_cache.go new file mode 100644 index 00000000..e354d7b9 --- /dev/null +++ b/internal/model/system_config_cache.go @@ -0,0 +1,116 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "context" + "encoding/json" + "sync" + + "github.com/Rain-kl/Wavelet/internal/db" + "github.com/Rain-kl/Wavelet/pkg/cache/ram" +) + +const ( + // SystemConfigInvalidationChannel broadcasts RAM cache eviction across nodes. + SystemConfigInvalidationChannel = "system:config_invalidation" + systemConfigInvalidateAllToken = "*" + systemConfigRAMMaximumSize = 512 +) + +type systemConfigInvalidationMessage struct { + Key string `json:"key"` +} + +var ( + systemConfigRAMCache = ram.MustNew[string, SystemConfig](ram.Options{MaximumSize: systemConfigRAMMaximumSize}) + systemConfigListenerOnce sync.Once +) + +func ensureSystemConfigCacheListener() { + systemConfigListenerOnce.Do(startSystemConfigCacheInvalidationListener) +} + +func startSystemConfigCacheInvalidationListener() { + if db.Redis == nil { + return + } + + go func() { + pubsub := db.Redis.Subscribe(context.Background(), SystemConfigInvalidationChannel) + defer func() { + _ = pubsub.Close() + }() + + for msg := range pubsub.Channel() { + var payload systemConfigInvalidationMessage + if err := json.Unmarshal([]byte(msg.Payload), &payload); err != nil { + systemConfigRAMCache.InvalidateAll() + continue + } + if payload.Key == "" || payload.Key == systemConfigInvalidateAllToken { + systemConfigRAMCache.InvalidateAll() + continue + } + systemConfigRAMCache.Invalidate(payload.Key) + } + }() +} + +func cloneSystemConfig(sc SystemConfig) SystemConfig { + return sc +} + +func populateSystemConfigCache(ctx context.Context, sc SystemConfig) { + systemConfigRAMCache.Set(sc.Key, cloneSystemConfig(sc)) + if db.Redis != nil { + _ = db.HSetJSON(ctx, SystemConfigRedisHashKey, sc.Key, &sc) + } +} + +func publishSystemConfigRAMInvalidation(ctx context.Context, key string) { + if db.Redis == nil { + return + } + payload, err := json.Marshal(systemConfigInvalidationMessage{Key: key}) + if err != nil { + return + } + _ = db.Redis.Publish(ctx, SystemConfigInvalidationChannel, payload).Err() +} + +// InvalidateSystemConfigCache evicts one config key from local RAM and Redis. +// It also publishes cluster-wide RAM invalidation when Redis is available. +func InvalidateSystemConfigCache(ctx context.Context, key string) error { + ensureSystemConfigCacheListener() + + systemConfigRAMCache.Invalidate(key) + if db.Redis != nil { + if err := db.HDel(ctx, SystemConfigRedisHashKey, key); err != nil { + return err + } + } + publishSystemConfigRAMInvalidation(ctx, key) + return nil +} + +// InvalidateAllSystemConfigCaches evicts all config entries from local RAM and Redis. +// It also publishes cluster-wide RAM invalidation when Redis is available. +func InvalidateAllSystemConfigCaches(ctx context.Context) error { + ensureSystemConfigCacheListener() + + systemConfigRAMCache.InvalidateAll() + if db.Redis != nil { + if err := db.Redis.Del(ctx, db.PrefixedKey(SystemConfigRedisHashKey)).Err(); err != nil { + return err + } + } + publishSystemConfigRAMInvalidation(ctx, systemConfigInvalidateAllToken) + return nil +} + +// ResetSystemConfigRAMCacheForTest clears only the process-local RAM cache. +func ResetSystemConfigRAMCacheForTest() { + systemConfigRAMCache.InvalidateAll() +} \ No newline at end of file diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index 5feea1f3..40554378 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -81,10 +81,18 @@ func (SystemConfig) TableName() string { return "w_system_configs" } -// GetByKey 通过 key 查询配置(带 Redis 缓存) +// GetByKey 通过 key 查询配置(带 RAM + Redis 缓存) func (sc *SystemConfig) GetByKey(ctx context.Context, key string) error { + ensureSystemConfigCacheListener() + + if cached, ok := systemConfigRAMCache.GetIfPresent(key); ok { + *sc = cloneSystemConfig(cached) + return nil + } + if db.Redis != nil { if err := db.HGetJSON(ctx, SystemConfigRedisHashKey, key, sc); err == nil { + systemConfigRAMCache.Set(key, cloneSystemConfig(*sc)) return nil } else if !errors.Is(err, redis.Nil) { // Redis 服务错误,返回错误 @@ -102,10 +110,7 @@ func (sc *SystemConfig) GetByKey(ctx context.Context, key string) error { return err } - // 更新 Redis Hash 缓存 - if db.Redis != nil { - _ = db.HSetJSON(ctx, SystemConfigRedisHashKey, key, sc) - } + populateSystemConfigCache(ctx, *sc) return nil } diff --git a/internal/storage/config.go b/internal/storage/config.go index 039d15ef..c9deb840 100644 --- a/internal/storage/config.go +++ b/internal/storage/config.go @@ -208,12 +208,7 @@ func upsertSystemConfig(ctx context.Context, tx *gorm.DB, key string, value any, FirstOrCreate(&sc).Error; err != nil { return err } - if db.Redis != nil { - if err := db.HSetJSON(ctx, model.SystemConfigRedisHashKey, key, &sc); err != nil { - return err - } - } - return nil + return model.InvalidateSystemConfigCache(ctx, key) } // MergeMaskedSecrets restores unchanged secrets from the current configuration. diff --git a/internal/testhelper/test_helper.go b/internal/testhelper/test_helper.go index 18862579..a5651e73 100644 --- a/internal/testhelper/test_helper.go +++ b/internal/testhelper/test_helper.go @@ -75,6 +75,7 @@ func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) // Cleanup function cleanup := func() { + model.ResetSystemConfigRAMCacheForTest() _ = redisClient.Close() mr.Close() // Reset database and Redis references diff --git a/main.go b/main.go index 045cff98..47fc90c6 100644 --- a/main.go +++ b/main.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 diff --git a/pkg/cache/ram/cache.go b/pkg/cache/ram/cache.go new file mode 100644 index 00000000..3b325a92 --- /dev/null +++ b/pkg/cache/ram/cache.go @@ -0,0 +1,73 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package ram provides a thin wrapper around Otter v2 for process-local caching. +package ram + +import ( + "github.com/maypok86/otter/v2" +) + +const defaultMaximumSize = 256 + +// Options configures a RAM cache instance. +type Options struct { + // MaximumSize bounds the number of entries. Zero uses a small default. + MaximumSize int +} + +// Cache is a concurrency-safe in-memory cache backed by Otter. +type Cache[K comparable, V any] struct { + inner *otter.Cache[K, V] +} + +// New creates a RAM cache from the provided options. +func New[K comparable, V any](opts Options) (*Cache[K, V], error) { + maximumSize := opts.MaximumSize + if maximumSize == 0 { + maximumSize = defaultMaximumSize + } + + inner, err := otter.New(&otter.Options[K, V]{ + MaximumSize: maximumSize, + }) + if err != nil { + return nil, err + } + + return &Cache[K, V]{inner: inner}, nil +} + +// MustNew creates a RAM cache and panics when configuration is invalid. +func MustNew[K comparable, V any](opts Options) *Cache[K, V] { + cache, err := New[K, V](opts) + if err != nil { + panic(err) + } + return cache +} + +// GetIfPresent returns the cached value when present. +func (c *Cache[K, V]) GetIfPresent(key K) (V, bool) { + return c.inner.GetIfPresent(key) +} + +// Set stores a value in the cache. +func (c *Cache[K, V]) Set(key K, value V) { + c.inner.Set(key, value) +} + +// Invalidate removes one entry from the cache. +func (c *Cache[K, V]) Invalidate(key K) { + c.inner.Invalidate(key) +} + +// InvalidateAll removes every entry from the cache. +func (c *Cache[K, V]) InvalidateAll() { + c.inner.InvalidateAll() +} + +// EstimatedSize returns the approximate number of cached entries. +func (c *Cache[K, V]) EstimatedSize() int { + return c.inner.EstimatedSize() +} \ No newline at end of file diff --git a/pkg/cache/ram/cache_test.go b/pkg/cache/ram/cache_test.go new file mode 100644 index 00000000..bdce673d --- /dev/null +++ b/pkg/cache/ram/cache_test.go @@ -0,0 +1,38 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package ram + +import "testing" + +func TestCacheSetGetInvalidate(t *testing.T) { + cache := MustNew[string, int](Options{MaximumSize: 8}) + + cache.Set("count", 3) + + got, ok := cache.GetIfPresent("count") + if !ok { + t.Fatal("GetIfPresent(count) ok = false, want true") + } + if got != 3 { + t.Fatalf("GetIfPresent(count) = %d, want %d", got, 3) + } + + cache.Invalidate("count") + if _, ok := cache.GetIfPresent("count"); ok { + t.Fatal("GetIfPresent(count) after Invalidate ok = true, want false") + } +} + +func TestCacheInvalidateAll(t *testing.T) { + cache := MustNew[string, string](Options{MaximumSize: 8}) + + cache.Set("a", "1") + cache.Set("b", "2") + + cache.InvalidateAll() + + if cache.EstimatedSize() != 0 { + t.Fatalf("EstimatedSize() after InvalidateAll = %d, want 0", cache.EstimatedSize()) + } +} \ No newline at end of file