mirror of
https://github.com/elyby/chrly.git
synced 2025-05-31 14:11:51 +05:30
Rework project's structure
This commit is contained in:
18
internal/db/model.go
Normal file
18
internal/db/model.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package db
|
||||
|
||||
type Profile struct {
|
||||
// Uuid contains user's UUID without dashes in lower case
|
||||
Uuid string
|
||||
// Username contains user's username with the original casing
|
||||
Username string
|
||||
// SkinUrl contains a valid URL to user's skin or an empty string in case the user doesn't have a skin
|
||||
SkinUrl string
|
||||
// SkinModel contains skin's model. It will be empty when the model is default
|
||||
SkinModel string
|
||||
// CapeUrl contains a valid URL to user's skin or an empty string in case the user doesn't have a cape
|
||||
CapeUrl string
|
||||
// MojangTextures contains the original textures value from Mojang's skinsystem
|
||||
MojangTextures string
|
||||
// MojangSignature contains the original textures signature from Mojang's skinsystem
|
||||
MojangSignature string
|
||||
}
|
232
internal/db/redis/redis.go
Normal file
232
internal/db/redis/redis.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mediocregopher/radix/v4"
|
||||
|
||||
"github.com/elyby/chrly/internal/db"
|
||||
)
|
||||
|
||||
const usernameToProfileKey = "hash:username-to-profile"
|
||||
const userUuidToUsernameKey = "hash:uuid-to-username"
|
||||
|
||||
type Redis struct {
|
||||
client radix.Client
|
||||
context context.Context
|
||||
serializer db.ProfileSerializer
|
||||
}
|
||||
|
||||
func New(ctx context.Context, profileSerializer db.ProfileSerializer, addr string, poolSize int) (*Redis, error) {
|
||||
client, err := (radix.PoolConfig{Size: poolSize}).New(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Redis{
|
||||
client: client,
|
||||
context: ctx,
|
||||
serializer: profileSerializer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Redis) FindProfileByUsername(username string) (*db.Profile, error) {
|
||||
var profile *db.Profile
|
||||
err := r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
var err error
|
||||
profile, err = r.findProfileByUsername(ctx, conn, username)
|
||||
|
||||
return err
|
||||
}))
|
||||
|
||||
return profile, err
|
||||
}
|
||||
|
||||
func (r *Redis) findProfileByUsername(ctx context.Context, conn radix.Conn, username string) (*db.Profile, error) {
|
||||
var encodedResult []byte
|
||||
err := conn.Do(ctx, radix.Cmd(&encodedResult, "HGET", usernameToProfileKey, usernameHashKey(username)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(encodedResult) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return r.serializer.Deserialize(encodedResult)
|
||||
}
|
||||
|
||||
func (r *Redis) FindProfileByUuid(uuid string) (*db.Profile, error) {
|
||||
var skin *db.Profile
|
||||
err := r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
var err error
|
||||
skin, err = r.findProfileByUuid(ctx, conn, uuid)
|
||||
|
||||
return err
|
||||
}))
|
||||
|
||||
return skin, err
|
||||
}
|
||||
|
||||
func (r *Redis) findProfileByUuid(ctx context.Context, conn radix.Conn, uuid string) (*db.Profile, error) {
|
||||
username, err := r.findUsernameHashKeyByUuid(ctx, conn, uuid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return r.findProfileByUsername(ctx, conn, username)
|
||||
}
|
||||
|
||||
func (r *Redis) findUsernameHashKeyByUuid(ctx context.Context, conn radix.Conn, uuid string) (string, error) {
|
||||
var username string
|
||||
return username, conn.Do(ctx, radix.FlatCmd(&username, "HGET", userUuidToUsernameKey, normalizeUuid(uuid)))
|
||||
}
|
||||
|
||||
func (r *Redis) SaveProfile(profile *db.Profile) error {
|
||||
return r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
return r.saveProfile(ctx, conn, profile)
|
||||
}))
|
||||
}
|
||||
|
||||
func (r *Redis) saveProfile(ctx context.Context, conn radix.Conn, profile *db.Profile) error {
|
||||
newUsernameHashKey := usernameHashKey(profile.Username)
|
||||
existsUsernameHashKey, err := r.findUsernameHashKeyByUuid(ctx, conn, profile.Uuid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "MULTI"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If user has changed username, then we must delete his old username record
|
||||
if existsUsernameHashKey != "" && existsUsernameHashKey != newUsernameHashKey {
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", usernameToProfileKey, existsUsernameHashKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", userUuidToUsernameKey, normalizeUuid(profile.Uuid), newUsernameHashKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serializedProfile, err := r.serializer.Serialize(profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", usernameToProfileKey, newUsernameHashKey, serializedProfile))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "EXEC"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Redis) RemoveProfileByUuid(uuid string) error {
|
||||
return r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
return r.removeProfileByUuid(ctx, conn, uuid)
|
||||
}))
|
||||
}
|
||||
|
||||
func (r *Redis) removeProfileByUuid(ctx context.Context, conn radix.Conn, uuid string) error {
|
||||
username, err := r.findUsernameHashKeyByUuid(ctx, conn, uuid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "MULTI"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", userUuidToUsernameKey, normalizeUuid(uuid)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if username != "" {
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", usernameToProfileKey, usernameHashKey(username)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return conn.Do(ctx, radix.Cmd(nil, "EXEC"))
|
||||
}
|
||||
|
||||
func (r *Redis) GetUuidForMojangUsername(username string) (string, string, error) {
|
||||
var uuid string
|
||||
foundUsername := username
|
||||
err := r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
var err error
|
||||
uuid, foundUsername, err = findMojangUuidByUsername(ctx, conn, username)
|
||||
|
||||
return err
|
||||
}))
|
||||
|
||||
return uuid, foundUsername, err
|
||||
}
|
||||
|
||||
func findMojangUuidByUsername(ctx context.Context, conn radix.Conn, username string) (string, string, error) {
|
||||
key := buildMojangUsernameKey(username)
|
||||
var result string
|
||||
err := conn.Do(ctx, radix.Cmd(&result, "GET", key))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if result == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
parts := strings.Split(result, ":")
|
||||
|
||||
return parts[1], parts[0], nil
|
||||
}
|
||||
|
||||
func (r *Redis) StoreMojangUuid(username string, uuid string) error {
|
||||
return r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
return storeMojangUuid(ctx, conn, username, uuid)
|
||||
}))
|
||||
}
|
||||
|
||||
func storeMojangUuid(ctx context.Context, conn radix.Conn, username string, uuid string) error {
|
||||
value := fmt.Sprintf("%s:%s", username, uuid)
|
||||
err := conn.Do(ctx, radix.FlatCmd(nil, "SET", buildMojangUsernameKey(username), value, "EX", 60*60*24*30))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Redis) Ping() error {
|
||||
return r.client.Do(r.context, radix.Cmd(nil, "PING"))
|
||||
}
|
||||
|
||||
func normalizeUuid(uuid string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(uuid, "-", ""))
|
||||
}
|
||||
|
||||
func usernameHashKey(username string) string {
|
||||
return strings.ToLower(username)
|
||||
}
|
||||
|
||||
func buildMojangUsernameKey(username string) string {
|
||||
return fmt.Sprintf("mojang:uuid:%s", usernameHashKey(username))
|
||||
}
|
299
internal/db/redis/redis_integration_test.go
Normal file
299
internal/db/redis/redis_integration_test.go
Normal file
@@ -0,0 +1,299 @@
|
||||
//go:build redis
|
||||
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/mediocregopher/radix/v4"
|
||||
"github.com/stretchr/testify/mock"
|
||||
assert "github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/elyby/chrly/internal/db"
|
||||
)
|
||||
|
||||
var redisAddr string
|
||||
|
||||
func init() {
|
||||
host := "localhost"
|
||||
port := 6379
|
||||
if os.Getenv("STORAGE_REDIS_HOST") != "" {
|
||||
host = os.Getenv("STORAGE_REDIS_HOST")
|
||||
}
|
||||
|
||||
if os.Getenv("STORAGE_REDIS_PORT") != "" {
|
||||
port, _ = strconv.Atoi(os.Getenv("STORAGE_REDIS_PORT"))
|
||||
}
|
||||
|
||||
redisAddr = fmt.Sprintf("%s:%d", host, port)
|
||||
}
|
||||
|
||||
type MockProfileSerializer struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockProfileSerializer) Serialize(profile *db.Profile) ([]byte, error) {
|
||||
args := m.Called(profile)
|
||||
|
||||
return []byte(args.String(0)), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockProfileSerializer) Deserialize(value []byte) (*db.Profile, error) {
|
||||
args := m.Called(value)
|
||||
var result *db.Profile
|
||||
if casted, ok := args.Get(0).(*db.Profile); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
t.Run("should connect", func(t *testing.T) {
|
||||
conn, err := New(context.Background(), &MockProfileSerializer{}, redisAddr, 12)
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, conn)
|
||||
})
|
||||
|
||||
t.Run("should return error", func(t *testing.T) {
|
||||
conn, err := New(context.Background(), &MockProfileSerializer{}, "localhost:12345", 12) // Use localhost to avoid DNS resolution
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, conn)
|
||||
})
|
||||
}
|
||||
|
||||
type redisTestSuite struct {
|
||||
suite.Suite
|
||||
|
||||
Redis *Redis
|
||||
Serializer *MockProfileSerializer
|
||||
|
||||
cmd func(cmd string, args ...interface{}) string
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) SetupSuite() {
|
||||
s.Serializer = &MockProfileSerializer{}
|
||||
|
||||
ctx := context.Background()
|
||||
conn, err := New(ctx, s.Serializer, redisAddr, 10)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot establish connection to redis: %w", err))
|
||||
}
|
||||
|
||||
s.Redis = conn
|
||||
s.cmd = func(cmd string, args ...interface{}) string {
|
||||
var result string
|
||||
err := s.Redis.client.Do(ctx, radix.FlatCmd(&result, cmd, args...))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) SetupSubTest() {
|
||||
// Cleanup database before each test
|
||||
s.cmd("FLUSHALL")
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) TearDownSubTest() {
|
||||
s.Serializer.AssertExpectations(s.T())
|
||||
for _, call := range s.Serializer.ExpectedCalls {
|
||||
call.Unset()
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedis(t *testing.T) {
|
||||
suite.Run(t, new(redisTestSuite))
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) TestFindProfileByUsername() {
|
||||
s.Run("exists record", func() {
|
||||
serializedData := []byte("mock.exists.profile")
|
||||
expectedProfile := &db.Profile{}
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", serializedData)
|
||||
s.Serializer.On("Deserialize", serializedData).Return(expectedProfile, nil)
|
||||
|
||||
profile, err := s.Redis.FindProfileByUsername("Mock")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Same(expectedProfile, profile)
|
||||
})
|
||||
|
||||
s.Run("not exists record", func() {
|
||||
profile, err := s.Redis.FindProfileByUsername("Mock")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(profile)
|
||||
})
|
||||
|
||||
s.Run("an error from serializer implementation", func() {
|
||||
expectedError := errors.New("mock error")
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", "some-invalid-mock-data")
|
||||
s.Serializer.On("Deserialize", mock.Anything).Return(nil, expectedError)
|
||||
|
||||
profile, err := s.Redis.FindProfileByUsername("Mock")
|
||||
s.Require().Nil(profile)
|
||||
s.Require().ErrorIs(err, expectedError)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) TestFindProfileByUuid() {
|
||||
s.Run("exists record", func() {
|
||||
serializedData := []byte("mock.exists.profile")
|
||||
expectedProfile := &db.Profile{Username: "Mock"}
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", serializedData)
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
s.Serializer.On("Deserialize", serializedData).Return(expectedProfile, nil)
|
||||
|
||||
profile, err := s.Redis.FindProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Same(expectedProfile, profile)
|
||||
})
|
||||
|
||||
s.Run("not exists record", func() {
|
||||
profile, err := s.Redis.FindProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(profile)
|
||||
})
|
||||
|
||||
s.Run("exists uuid record, but related profile not exists", func() {
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
profile, err := s.Redis.FindProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(profile)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) TestSaveProfile() {
|
||||
s.Run("save new entity", func() {
|
||||
profile := &db.Profile{
|
||||
Uuid: "f57f36d5-4f50-4728-948a-42d5d80b18f3",
|
||||
Username: "Mock",
|
||||
}
|
||||
serializedProfile := "serialized-profile"
|
||||
s.Serializer.On("Serialize", profile).Return(serializedProfile, nil)
|
||||
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", serializedProfile)
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
|
||||
err := s.Redis.SaveProfile(profile)
|
||||
s.Require().NoError(err)
|
||||
|
||||
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
|
||||
s.Require().Equal("mock", uuidResp)
|
||||
|
||||
profileResp := s.cmd("HGET", usernameToProfileKey, "mock")
|
||||
s.Require().Equal(serializedProfile, profileResp)
|
||||
})
|
||||
|
||||
s.Run("update exists record with changed username", func() {
|
||||
newProfile := &db.Profile{
|
||||
Uuid: "f57f36d5-4f50-4728-948a-42d5d80b18f3",
|
||||
Username: "NewMock",
|
||||
}
|
||||
serializedNewProfile := "serialized-new-profile"
|
||||
s.Serializer.On("Serialize", newProfile).Return(serializedNewProfile, nil)
|
||||
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", "serialized-old-profile")
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
|
||||
err := s.Redis.SaveProfile(newProfile)
|
||||
s.Require().NoError(err)
|
||||
|
||||
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
|
||||
s.Require().Equal("newmock", uuidResp)
|
||||
|
||||
newProfileResp := s.cmd("HGET", usernameToProfileKey, "newmock")
|
||||
s.Require().Equal(serializedNewProfile, newProfileResp)
|
||||
|
||||
oldProfileResp := s.cmd("HGET", usernameToProfileKey, "mock")
|
||||
s.Require().Empty(oldProfileResp)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) TestRemoveProfileByUuid() {
|
||||
s.Run("exists record", func() {
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", "serialized-profile")
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
|
||||
err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
|
||||
s.Require().NoError(err)
|
||||
|
||||
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
|
||||
s.Require().Empty(uuidResp)
|
||||
|
||||
profileResp := s.cmd("HGET", usernameToProfileKey, "mock")
|
||||
s.Require().Empty(profileResp)
|
||||
})
|
||||
|
||||
s.Run("uuid exists, username is missing", func() {
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
|
||||
err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
|
||||
s.Require().NoError(err)
|
||||
|
||||
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
|
||||
s.Require().Empty(uuidResp)
|
||||
})
|
||||
|
||||
s.Run("uuid not exists", func() {
|
||||
err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
|
||||
s.Require().NoError(err)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) TestGetUuidForMojangUsername() {
|
||||
s.Run("exists record", func() {
|
||||
s.cmd("SET", "mojang:uuid:mock", "MoCk:d3ca513eb3e14946b58047f2bd3530fd")
|
||||
|
||||
uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal("MoCk", username)
|
||||
s.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
|
||||
})
|
||||
|
||||
s.Run("exists record with empty uuid value", func() {
|
||||
s.cmd("SET", "mojang:uuid:mock", "MoCk:")
|
||||
|
||||
uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal("MoCk", username)
|
||||
s.Require().Empty(uuid)
|
||||
})
|
||||
|
||||
s.Run("not exists record", func() {
|
||||
uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Empty(username)
|
||||
s.Require().Empty(uuid)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) TestStoreUuid() {
|
||||
s.Run("store uuid", func() {
|
||||
err := s.Redis.StoreMojangUuid("MoCk", "d3ca513eb3e14946b58047f2bd3530fd")
|
||||
s.Require().NoError(err)
|
||||
|
||||
resp := s.cmd("GET", "mojang:uuid:mock")
|
||||
s.Require().Equal(resp, "MoCk:d3ca513eb3e14946b58047f2bd3530fd")
|
||||
})
|
||||
|
||||
s.Run("store empty uuid", func() {
|
||||
err := s.Redis.StoreMojangUuid("MoCk", "")
|
||||
s.Require().NoError(err)
|
||||
|
||||
resp := s.cmd("GET", "mojang:uuid:mock")
|
||||
s.Require().Equal(resp, "MoCk:")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) TestPing() {
|
||||
err := s.Redis.Ping()
|
||||
s.Require().Nil(err)
|
||||
}
|
136
internal/db/serializer.go
Normal file
136
internal/db/serializer.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/valyala/fastjson"
|
||||
)
|
||||
|
||||
type ProfileSerializer interface {
|
||||
Serialize(profile *Profile) ([]byte, error)
|
||||
Deserialize(value []byte) (*Profile, error)
|
||||
}
|
||||
|
||||
func NewJsonSerializer() *JsonSerializer {
|
||||
return &JsonSerializer{
|
||||
parserPool: &fastjson.ParserPool{},
|
||||
}
|
||||
}
|
||||
|
||||
type JsonSerializer struct {
|
||||
parserPool *fastjson.ParserPool
|
||||
}
|
||||
|
||||
// Reasons for manual JSON serialization:
|
||||
// 1. The Profile must be pure and must not contain tags.
|
||||
// 2. Without tags it's impossible to apply omitempty during serialization.
|
||||
// 3. Without omitempty we significantly inflate the storage size, which is critical for large deployments.
|
||||
// Since the JSON structure in this case is very simple, it's very easy to write a manual serialization,
|
||||
// achieving all constraints above.
|
||||
func (s *JsonSerializer) Serialize(profile *Profile) ([]byte, error) {
|
||||
var builder strings.Builder
|
||||
// Prepare for the worst case (e.g. long username, long textures links, long Mojang textures and signature)
|
||||
// to prevent additional memory allocations during serialization
|
||||
builder.Grow(1536)
|
||||
builder.WriteString(`{"uuid":"`)
|
||||
builder.WriteString(profile.Uuid)
|
||||
builder.WriteString(`","username":"`)
|
||||
builder.WriteString(profile.Username)
|
||||
builder.WriteString(`"`)
|
||||
if profile.SkinUrl != "" {
|
||||
builder.WriteString(`,"skinUrl":"`)
|
||||
builder.WriteString(profile.SkinUrl)
|
||||
builder.WriteString(`"`)
|
||||
if profile.SkinModel != "" {
|
||||
builder.WriteString(`,"skinModel":"`)
|
||||
builder.WriteString(profile.SkinModel)
|
||||
builder.WriteString(`"`)
|
||||
}
|
||||
}
|
||||
|
||||
if profile.CapeUrl != "" {
|
||||
builder.WriteString(`,"capeUrl":"`)
|
||||
builder.WriteString(profile.CapeUrl)
|
||||
builder.WriteString(`"`)
|
||||
}
|
||||
|
||||
if profile.MojangTextures != "" {
|
||||
builder.WriteString(`,"mojangTextures":"`)
|
||||
builder.WriteString(profile.MojangTextures)
|
||||
builder.WriteString(`","mojangSignature":"`)
|
||||
builder.WriteString(profile.MojangSignature)
|
||||
builder.WriteString(`"`)
|
||||
}
|
||||
|
||||
builder.WriteString("}")
|
||||
|
||||
return []byte(builder.String()), nil
|
||||
}
|
||||
|
||||
func (s *JsonSerializer) Deserialize(value []byte) (*Profile, error) {
|
||||
parser := s.parserPool.Get()
|
||||
defer s.parserPool.Put(parser)
|
||||
v, err := parser.ParseBytes(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profile := &Profile{
|
||||
Uuid: string(v.GetStringBytes("uuid")),
|
||||
Username: string(v.GetStringBytes("username")),
|
||||
SkinUrl: string(v.GetStringBytes("skinUrl")),
|
||||
SkinModel: string(v.GetStringBytes("skinModel")),
|
||||
CapeUrl: string(v.GetStringBytes("capeUrl")),
|
||||
MojangTextures: string(v.GetStringBytes("mojangTextures")),
|
||||
MojangSignature: string(v.GetStringBytes("mojangSignature")),
|
||||
}
|
||||
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func NewZlibEncoder(serializer ProfileSerializer) *ZlibEncoder {
|
||||
return &ZlibEncoder{serializer}
|
||||
}
|
||||
|
||||
type ZlibEncoder struct {
|
||||
serializer ProfileSerializer
|
||||
}
|
||||
|
||||
func (s *ZlibEncoder) Serialize(profile *Profile) ([]byte, error) {
|
||||
serialized, err := s.serializer.Serialize(profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var buff bytes.Buffer
|
||||
writer := zlib.NewWriter(&buff)
|
||||
_, err = writer.Write(serialized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = writer.Close()
|
||||
|
||||
return buff.Bytes(), nil
|
||||
}
|
||||
|
||||
func (s *ZlibEncoder) Deserialize(value []byte) (*Profile, error) {
|
||||
buff := bytes.NewReader(value)
|
||||
reader, err := zlib.NewReader(buff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resultBuffer := new(bytes.Buffer)
|
||||
_, err = io.Copy(resultBuffer, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = reader.Close()
|
||||
|
||||
return s.serializer.Deserialize(resultBuffer.Bytes())
|
||||
}
|
194
internal/db/serializer_test.go
Normal file
194
internal/db/serializer_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestJsonSerializer(t *testing.T) {
|
||||
var testCases = map[string]*struct {
|
||||
*Profile
|
||||
Serialized []byte
|
||||
Error error
|
||||
}{
|
||||
"full structure": {
|
||||
Profile: &Profile{
|
||||
Uuid: "f57f36d54f504728948a42d5d80b18f3",
|
||||
Username: "mock-username",
|
||||
SkinUrl: "https://example.com/skin.png",
|
||||
SkinModel: "slim",
|
||||
CapeUrl: "https://example.com/cape.png",
|
||||
MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=",
|
||||
MojangSignature: "QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=",
|
||||
},
|
||||
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","skinUrl":"https://example.com/skin.png","skinModel":"slim","capeUrl":"https://example.com/cape.png","mojangTextures":"eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=","mojangSignature":"QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc="}`),
|
||||
},
|
||||
"default skin model": {
|
||||
Profile: &Profile{
|
||||
Uuid: "f57f36d54f504728948a42d5d80b18f3",
|
||||
Username: "mock-username",
|
||||
SkinUrl: "https://example.com/skin.png",
|
||||
},
|
||||
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","skinUrl":"https://example.com/skin.png"}`),
|
||||
},
|
||||
"cape only": {
|
||||
Profile: &Profile{
|
||||
Uuid: "f57f36d54f504728948a42d5d80b18f3",
|
||||
Username: "mock-username",
|
||||
CapeUrl: "https://example.com/cape.png",
|
||||
},
|
||||
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","capeUrl":"https://example.com/cape.png"}`),
|
||||
},
|
||||
"minimal structure": {
|
||||
Profile: &Profile{
|
||||
Uuid: "f57f36d54f504728948a42d5d80b18f3",
|
||||
Username: "mock-username",
|
||||
},
|
||||
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username"}`),
|
||||
},
|
||||
"invalid json structure": {
|
||||
Serialized: []byte(`this is not json`),
|
||||
Error: errors.New(`cannot parse JSON: unexpected value found: "this is not json"; unparsed tail: "this is not json"`),
|
||||
},
|
||||
}
|
||||
|
||||
serializer := NewJsonSerializer()
|
||||
t.Run("Serialize", func(t *testing.T) {
|
||||
for n, c := range testCases {
|
||||
if c.Profile == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(n, func(t *testing.T) {
|
||||
result, err := serializer.Serialize(c.Profile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.Serialized, result)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deserialize", func(t *testing.T) {
|
||||
for n, c := range testCases {
|
||||
t.Run(n, func(t *testing.T) {
|
||||
result, err := serializer.Deserialize(c.Serialized)
|
||||
require.Equal(t, c.Error, err)
|
||||
require.Equal(t, c.Profile, result)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type ProfileSerializerMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *ProfileSerializerMock) Serialize(profile *Profile) ([]byte, error) {
|
||||
args := m.Called(profile)
|
||||
var result []byte
|
||||
if casted, ok := args.Get(0).([]byte); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *ProfileSerializerMock) Deserialize(value []byte) (*Profile, error) {
|
||||
args := m.Called(value)
|
||||
var result *Profile
|
||||
if casted, ok := args.Get(0).(*Profile); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
func TestZlibEncoder(t *testing.T) {
|
||||
profile := &Profile{
|
||||
Uuid: "f57f36d54f504728948a42d5d80b18f3",
|
||||
Username: "mock-username",
|
||||
}
|
||||
|
||||
t.Run("Serialize", func(t *testing.T) {
|
||||
t.Run("successfully", func(t *testing.T) {
|
||||
serializer := &ProfileSerializerMock{}
|
||||
serializer.On("Serialize", profile).Return([]byte("serialized-string"), nil)
|
||||
encoder := NewZlibEncoder(serializer)
|
||||
|
||||
result, err := encoder.Serialize(profile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte{0x78, 0x9c, 0x2a, 0x4e, 0x2d, 0xca, 0x4c, 0xcc, 0xc9, 0xac, 0x4a, 0x4d, 0xd1, 0x2d, 0x2e, 0x29, 0xca, 0xcc, 0x4b, 0x7, 0x4, 0x0, 0x0, 0xff, 0xff, 0x3e, 0xd8, 0x6, 0xf1}, result)
|
||||
})
|
||||
|
||||
t.Run("handle error from serializer", func(t *testing.T) {
|
||||
expectedError := errors.New("mock error")
|
||||
serializer := &ProfileSerializerMock{}
|
||||
serializer.On("Serialize", profile).Return(nil, expectedError)
|
||||
encoder := NewZlibEncoder(serializer)
|
||||
|
||||
result, err := encoder.Serialize(profile)
|
||||
require.Same(t, expectedError, err)
|
||||
require.Nil(t, result)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Deserialize", func(t *testing.T) {
|
||||
t.Run("successfully", func(t *testing.T) {
|
||||
serializer := &ProfileSerializerMock{}
|
||||
serializer.On("Deserialize", []byte("serialized-string")).Return(profile, nil)
|
||||
encoder := NewZlibEncoder(serializer)
|
||||
|
||||
result, err := encoder.Deserialize([]byte{0x78, 0x9c, 0x2a, 0x4e, 0x2d, 0xca, 0x4c, 0xcc, 0xc9, 0xac, 0x4a, 0x4d, 0xd1, 0x2d, 0x2e, 0x29, 0xca, 0xcc, 0x4b, 0x7, 0x4, 0x0, 0x0, 0xff, 0xff, 0x3e, 0xd8, 0x6, 0xf1})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, profile, result)
|
||||
})
|
||||
|
||||
t.Run("handle an error from deserializer", func(t *testing.T) {
|
||||
expectedError := errors.New("mock error")
|
||||
|
||||
serializer := &ProfileSerializerMock{}
|
||||
serializer.On("Deserialize", []byte("serialized-string")).Return(nil, expectedError)
|
||||
encoder := NewZlibEncoder(serializer)
|
||||
|
||||
result, err := encoder.Deserialize([]byte{0x78, 0x9c, 0x2a, 0x4e, 0x2d, 0xca, 0x4c, 0xcc, 0xc9, 0xac, 0x4a, 0x4d, 0xd1, 0x2d, 0x2e, 0x29, 0xca, 0xcc, 0x4b, 0x7, 0x4, 0x0, 0x0, 0xff, 0xff, 0x3e, 0xd8, 0x6, 0xf1})
|
||||
require.Same(t, expectedError, err)
|
||||
require.Nil(t, result)
|
||||
})
|
||||
|
||||
t.Run("handle invalid zlib encoding", func(t *testing.T) {
|
||||
encoder := NewZlibEncoder(&ProfileSerializerMock{})
|
||||
|
||||
result, err := encoder.Deserialize([]byte{0x6d, 0x6f, 0x63, 0x6b})
|
||||
require.ErrorContains(t, err, "invalid")
|
||||
require.Nil(t, result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkFastJsonSerializer(b *testing.B) {
|
||||
profile := &Profile{
|
||||
Uuid: "f57f36d54f504728948a42d5d80b18f3",
|
||||
Username: "mock-username",
|
||||
SkinUrl: "https://example.com/skin.png",
|
||||
SkinModel: "slim",
|
||||
CapeUrl: "https://example.com/cape.png",
|
||||
MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=",
|
||||
MojangSignature: "QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=",
|
||||
}
|
||||
serializedProfile := []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","skinUrl":"https://example.com/skin.png","skinModel":"slim","capeUrl":"https://example.com/cape.png","mojangTextures":"eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=","mojangSignature":"QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc="}`)
|
||||
|
||||
serializer := NewJsonSerializer()
|
||||
b.Run("Serialize", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = serializer.Serialize(profile)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("Deserialize", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = serializer.Deserialize(serializedProfile)
|
||||
}
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user