From f12d54ee5c3f9153093b1d6b2c61ac7bed94bdfe Mon Sep 17 00:00:00 2001 From: ras0q Date: Thu, 19 Feb 2026 22:49:21 +0900 Subject: [PATCH 1/3] perf: cache with etag --- repository/gorm/stamp.go | 15 ++++++++---- repository/gorm/stamp_test.go | 4 ++-- repository/stamp.go | 3 ++- router/extension/precond.go | 21 +++++++++++++++++ router/v1/public.go | 6 ++--- utils/etag/etag.go | 43 +++++++++++++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 utils/etag/etag.go diff --git a/repository/gorm/stamp.go b/repository/gorm/stamp.go index e7c4313f0..0f4aff578 100644 --- a/repository/gorm/stamp.go +++ b/repository/gorm/stamp.go @@ -15,6 +15,7 @@ import ( "github.com/traPtitech/traQ/event" "github.com/traPtitech/traQ/model" "github.com/traPtitech/traQ/repository" + "github.com/traPtitech/traQ/utils/etag" "github.com/traPtitech/traQ/utils/gormutil" "github.com/traPtitech/traQ/utils/validator" ) @@ -25,7 +26,7 @@ type stampRepository struct { db *gorm.DB hub *hub.Hub stamps *sc.Cache[struct{}, map[uuid.UUID]*model.Stamp] - perType *sc.Cache[repository.StampType, []*model.StampWithThumbnail] + perType *sc.Cache[repository.StampType, *etag.Entity[[]*model.StampWithThumbnail]] } func makeStampRepository(db *gorm.DB, hub *hub.Hub) *stampRepository { @@ -48,7 +49,7 @@ func (r *stampRepository) loadStamps(_ context.Context, _ struct{}) (map[uuid.UU return stampsMap, nil } -func (r *stampRepository) loadFilteredStamps(ctx context.Context, stampType repository.StampType) ([]*model.StampWithThumbnail, error) { +func (r *stampRepository) loadFilteredStamps(ctx context.Context, stampType repository.StampType) (*etag.Entity[[]*model.StampWithThumbnail], error) { stamps, err := r.stamps.Get(ctx, struct{}{}) if err != nil { return nil, err @@ -106,7 +107,13 @@ func (r *stampRepository) loadFilteredStamps(ctx context.Context, stampType repo } sort.Slice(arr, func(i, j int) bool { return arr[i].ID.String() < arr[j].ID.String() }) - return arr, nil + + stampsWithEtag, err := etag.New(arr) + if err != nil { + return nil, err + } + + return stampsWithEtag, nil } func (r *stampRepository) purgeCache() { @@ -304,7 +311,7 @@ func (r *stampRepository) DeleteStamp(ctx context.Context, id uuid.UUID) (err er } // GetAllStampsWithThumbnail implements StampRepository interface. -func (r *stampRepository) GetAllStampsWithThumbnail(ctx context.Context, stampType repository.StampType) (stampsWithThumbnail []*model.StampWithThumbnail, err error) { +func (r *stampRepository) GetAllStampsWithThumbnail(ctx context.Context, stampType repository.StampType) (*etag.Entity[[]*model.StampWithThumbnail], error) { return r.perType.Get(ctx, stampType) } diff --git a/repository/gorm/stamp_test.go b/repository/gorm/stamp_test.go index 192f50f22..dd927ae9b 100644 --- a/repository/gorm/stamp_test.go +++ b/repository/gorm/stamp_test.go @@ -254,7 +254,7 @@ func TestRepositoryImpl_GetAllStampsWithThumbnail(t *testing.T) { } assert.Len(arr, n*2) cnt := 0 - for _, s := range arr { + for _, s := range arr.Value() { if !s.HasThumbnail { cnt++ } @@ -269,7 +269,7 @@ func TestRepositoryImpl_GetAllStampsWithThumbnail(t *testing.T) { } assert.Len(arr, n*2) cnt := 0 - for _, s := range arr { + for _, s := range arr.Value() { if s.HasThumbnail { cnt++ } diff --git a/repository/stamp.go b/repository/stamp.go index a1bb0436a..1a6a0e056 100644 --- a/repository/stamp.go +++ b/repository/stamp.go @@ -7,6 +7,7 @@ import ( "github.com/gofrs/uuid" "github.com/traPtitech/traQ/model" + "github.com/traPtitech/traQ/utils/etag" "github.com/traPtitech/traQ/utils/optional" ) @@ -96,7 +97,7 @@ type StampRepository interface { // // 成功した場合、スタンプのIDでソートされた配列とnilを返します。 // DBによるエラーを返すことがあります。 - GetAllStampsWithThumbnail(ctx context.Context, stampType StampType) (stamps []*model.StampWithThumbnail, err error) + GetAllStampsWithThumbnail(ctx context.Context, stampType StampType) (stampsWithETag *etag.Entity[[]*model.StampWithThumbnail], err error) // StampExists 指定したIDのスタンプが存在するかどうかを返します // // 存在する場合、trueとnilを返します。 diff --git a/router/extension/precond.go b/router/extension/precond.go index 06030a655..2f489278d 100644 --- a/router/extension/precond.go +++ b/router/extension/precond.go @@ -12,6 +12,7 @@ import ( "github.com/labstack/echo/v4" "github.com/traPtitech/traQ/router/consts" + "github.com/traPtitech/traQ/utils/etag" ) func scanETag(s string) (eTag string, remain string) { @@ -223,3 +224,23 @@ func ServeWithETag(c echo.Context, contentType string, bytes []byte) error { } return c.Blob(http.StatusOK, contentType, bytes) } + +// ServeJSONWithPrecomputedETag 事前に計算されたEtagを付与してJSONを返します。リクエストの条件に合うときは304を返します。 +func ServeJSONWithPrecomputedETag[T any](c echo.Context, e etag.Entity[T]) error { + // NOTE: prettyクエリがあるときはEtagを無視して整形されたJSONを返す + if _, pretty := c.QueryParams()["pretty"]; pretty { + return c.JSONPretty(http.StatusOK, e.Value(), " ") + } + + c.Response().Header().Set(consts.HeaderETag, "\""+e.ETag()+"\"") + + done, err := CheckPreconditions(c, time.Time{}) + if err != nil { + return err + } + if done { + return nil + } + + return c.JSON(http.StatusOK, e.Value()) +} diff --git a/router/v1/public.go b/router/v1/public.go index 890992e87..8522b0e00 100644 --- a/router/v1/public.go +++ b/router/v1/public.go @@ -123,8 +123,8 @@ func emojiJSONGenerator(repo repository.Repository) func(_ context.Context, _ st return nil, err } - stampNames := make([]string, len(stamps)) - for i, stamp := range stamps { + stampNames := make([]string, len(stamps.Value())) + for i, stamp := range stamps.Value() { stampNames[i] = stamp.Name } @@ -151,7 +151,7 @@ func emojiCSSGenerator(repo repository.Repository) func(_ context.Context, _ str buf.WriteString(".s16{width:16px;height:16px}") buf.WriteString(".s24{width:24px;height:24px}") buf.WriteString(".s32{width:32px;height:32px}") - for _, stamp := range stamps { + for _, stamp := range stamps.Value() { buf.WriteString(fmt.Sprintf(".emoji.e_%s{background-image:url(/api/1.0/public/emoji/%s)}", stamp.Name, stamp.ID)) } return buf.Bytes(), nil diff --git a/utils/etag/etag.go b/utils/etag/etag.go new file mode 100644 index 000000000..d6879237b --- /dev/null +++ b/utils/etag/etag.go @@ -0,0 +1,43 @@ +package etag + +import ( + "crypto/md5" + "encoding/hex" + + jsonIter "github.com/json-iterator/go" +) + +type Entity[T any] struct { + value T + etag string +} + +var jsonIterForETag = jsonIter.Config{ + EscapeHTML: false, + MarshalFloatWith6Digits: true, + ObjectFieldMustBeSimpleString: true, + // ここより上はjsonIter.ConfigFastestと同様 + SortMapKeys: true, // 順番が一致しないとEtagが一致しないのでソートを有効にする +}.Froze() + +func New[T any](data T) (*Entity[T], error) { + b, err := jsonIterForETag.Marshal(data) + if err != nil { + return nil, err + } + + md5Res := md5.Sum(b) + + return &Entity[T]{ + value: data, + etag: hex.EncodeToString(md5Res[:]), + }, nil +} + +func (e Entity[T]) Value() T { + return e.value +} + +func (e Entity[T]) ETag() string { + return e.etag +} From 855e3da6d16103b477a19f1a96f84f99492ab94a Mon Sep 17 00:00:00 2001 From: ras0q Date: Thu, 19 Feb 2026 23:22:02 +0900 Subject: [PATCH 2/3] test: fix cases --- repository/gorm/stamp_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/repository/gorm/stamp_test.go b/repository/gorm/stamp_test.go index dd927ae9b..162d36740 100644 --- a/repository/gorm/stamp_test.go +++ b/repository/gorm/stamp_test.go @@ -248,13 +248,13 @@ func TestRepositoryImpl_GetAllStampsWithThumbnail(t *testing.T) { t.Run("without thumbnail", func(t *testing.T) { t.Parallel() - arr, err := repo.GetAllStampsWithThumbnail(context.TODO(), repository.StampTypeAll) + stamps, err := repo.GetAllStampsWithThumbnail(context.TODO(), repository.StampTypeAll) if !assert.NoError(err) { t.FailNow() } - assert.Len(arr, n*2) + assert.Len(stamps.Value(), n*2) cnt := 0 - for _, s := range arr.Value() { + for _, s := range stamps.Value() { if !s.HasThumbnail { cnt++ } @@ -263,13 +263,13 @@ func TestRepositoryImpl_GetAllStampsWithThumbnail(t *testing.T) { }) t.Run("with thumbnail", func(t *testing.T) { t.Parallel() - arr, err := repo.GetAllStampsWithThumbnail(context.TODO(), repository.StampTypeAll) + stamps, err := repo.GetAllStampsWithThumbnail(context.TODO(), repository.StampTypeAll) if !assert.NoError(err) { t.FailNow() } - assert.Len(arr, n*2) + assert.Len(stamps.Value(), n*2) cnt := 0 - for _, s := range arr.Value() { + for _, s := range stamps.Value() { if s.HasThumbnail { cnt++ } From f50555faed8803aa08a1b97f370ba9b76da517f9 Mon Sep 17 00:00:00 2001 From: ras0q Date: Thu, 19 Feb 2026 23:37:10 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20ServeJSON=E3=81=AB=E6=88=BB=E3=81=A3?= =?UTF-8?q?=E3=81=A6=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- router/extension/precond.go | 2 +- router/v3/stamps.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/router/extension/precond.go b/router/extension/precond.go index 2f489278d..3cec12b92 100644 --- a/router/extension/precond.go +++ b/router/extension/precond.go @@ -226,7 +226,7 @@ func ServeWithETag(c echo.Context, contentType string, bytes []byte) error { } // ServeJSONWithPrecomputedETag 事前に計算されたEtagを付与してJSONを返します。リクエストの条件に合うときは304を返します。 -func ServeJSONWithPrecomputedETag[T any](c echo.Context, e etag.Entity[T]) error { +func ServeJSONWithPrecomputedETag[T any](c echo.Context, e *etag.Entity[T]) error { // NOTE: prettyクエリがあるときはEtagを無視して整形されたJSONを返す if _, pretty := c.QueryParams()["pretty"]; pretty { return c.JSONPretty(http.StatusOK, e.Value(), " ") diff --git a/router/v3/stamps.go b/router/v3/stamps.go index 2e3402b09..7174d0996 100644 --- a/router/v3/stamps.go +++ b/router/v3/stamps.go @@ -71,7 +71,7 @@ func (h *Handlers) GetStamps(c echo.Context) error { return herror.InternalServerError(err) } - return extension.ServeJSONWithETag(c, stamps) + return extension.ServeJSONWithPrecomputedETag(c, stamps) } // CreateStamp POST /stamps