diff --git a/conf/nuxbt.yml b/conf/nuxbt.yml index f0590fc..0532a6c 100644 --- a/conf/nuxbt.yml +++ b/conf/nuxbt.yml @@ -6,7 +6,7 @@ server: requestLimit: 50 # 50 times per minute jwt: - timeout: 60 # 1 hour + timeout: 600 # minute key: nuxbt log: diff --git a/go.mod b/go.mod index 3f45e39..ee9ffe3 100644 --- a/go.mod +++ b/go.mod @@ -9,16 +9,18 @@ require ( github.com/fsnotify/fsnotify v1.7.0 github.com/gin-gonic/gin v1.10.0 github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/jellydator/ttlcache/v2 v2.11.1 github.com/redis/go-redis/v9 v9.5.4 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 github.com/ulule/limiter/v3 v3.11.2 github.com/urfave/cli/v2 v2.27.2 golang.org/x/crypto v0.25.0 + golang.org/x/sync v0.7.0 gorm.io/driver/mysql v1.5.7 gorm.io/driver/postgres v1.5.9 gorm.io/gen v0.3.26 - gorm.io/gorm v1.25.10 + gorm.io/gorm v1.25.11 gorm.io/plugin/dbresolver v1.5.2 ) @@ -98,7 +100,6 @@ require ( golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect golang.org/x/mod v0.19.0 // indirect golang.org/x/net v0.27.0 // indirect - golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.22.0 // indirect golang.org/x/text v0.16.0 // indirect golang.org/x/tools v0.23.0 // indirect diff --git a/go.sum b/go.sum index b433df7..3767623 100644 --- a/go.sum +++ b/go.sum @@ -113,6 +113,8 @@ github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jellydator/ttlcache/v2 v2.11.1 h1:AZGME43Eh2Vv3giG6GeqeLeFXxwxn1/qHItqWZl6U64= +github.com/jellydator/ttlcache/v2 v2.11.1/go.mod h1:RtE5Snf0/57e+2cLWFYWCCsLas2Hy3c5Z4n14XmSvTI= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -125,8 +127,11 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02 github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -189,6 +194,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -208,36 +214,70 @@ github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20210112230658-8b4aab62c064/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= @@ -261,8 +301,8 @@ gorm.io/gen v0.3.26/go.mod h1:a5lq5y3w4g5LMxBcw0wnO6tYUCdNutWODq5LrIt75LE= gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= -gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= +gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gorm.io/hints v1.1.2 h1:b5j0kwk5p4+3BtDtYqqfY+ATSxjj+6ptPgVveuynn9o= gorm.io/hints v1.1.2/go.mod h1:/ARdpUHAtyEMCh5NNi3tI7FsGh+Cj/MIUlvNxCNCFWg= gorm.io/plugin/dbresolver v1.5.2 h1:Iut7lW4TXNoVs++I+ra3zxjSxTRj4ocIeFEVp4lLhII= diff --git a/internal/middleware/cache/response.go b/internal/middleware/cache/response.go index 0f300ec..77b5ea1 100644 --- a/internal/middleware/cache/response.go +++ b/internal/middleware/cache/response.go @@ -1,35 +1,49 @@ package cache import ( + "github.com/TensoRaws/NuxBT-Backend/module/util" + "time" + "github.com/TensoRaws/NuxBT-Backend/module/cache" "github.com/TensoRaws/NuxBT-Backend/module/log" - "github.com/TensoRaws/NuxBT-Backend/module/util" + "github.com/TensoRaws/NuxBT-Backend/third_party/gin_cache" + "github.com/TensoRaws/NuxBT-Backend/third_party/gin_cache/persist" "github.com/gin-gonic/gin" - "time" ) // Response 缓存接口响应的中间件 -func Response(redisClient *cache.Client, ttl time.Duration) gin.HandlerFunc { - return func(c *gin.Context) { - // 生成缓存键,使用请求的 URL 和方法 - cacheKey := c.Request.Method + ":" + c.Request.URL.String() - - // 尝试从缓存中获取响应 - cachedResponse, err := redisClient.Get(cacheKey).Result() - if err == nil { - // 缓存命中,直接返回缓存的响应 - util.OKWithCache(c, cachedResponse) - log.Logger.Debug("Cache hit: " + cacheKey) - return - } +func Response(redisClient *cache.Client, ttl time.Duration, queryFilter []string) gin.HandlerFunc { + redisStore := persist.NewRedisStore(redisClient.C) - // 缓存未命中,调用后续的处理函数 - c.Next() - - // 调用结束后,将结果存入缓存 - result, exists := c.Get("cache") - if exists { - redisClient.Set(cacheKey, result, ttl) - } + var strategy gin_cache.Option + if queryFilter != nil { + strategy = gin_cache.WithCacheStrategyByRequest(func(c *gin.Context) (bool, gin_cache.Strategy) { + // 剔除 query 参数 + return true, gin_cache.Strategy{ + CacheKey: util.RemoveQueryParameter(c.Request.RequestURI, queryFilter...), + } + }) + } else { + strategy = gin_cache.WithCacheStrategyByRequest(func(c *gin.Context) (bool, gin_cache.Strategy) { + return true, gin_cache.Strategy{ + CacheKey: c.Request.RequestURI, + } + }) } + + return gin_cache.CacheByRequestURI( + redisStore, + ttl, + gin_cache.WithOnHitCache( + func(c *gin.Context) { + log.Logger.Info("Cache hit: " + c.Request.RequestURI) + }, + ), + gin_cache.WithOnMissCache( + func(c *gin.Context) { + log.Logger.Info("Cache miss, try to cache: " + c.Request.RequestURI) + }, + ), + strategy, + ) } diff --git a/internal/router/api/v1/api.go b/internal/router/api/v1/api.go index a6ecb4e..7fab083 100644 --- a/internal/router/api/v1/api.go +++ b/internal/router/api/v1/api.go @@ -44,7 +44,7 @@ func NewAPI() *gin.Engine { user.GET("profile/me", middleware_cache.JWTBlacklist(cache.Clients[cache.JWTBlacklist], false), jwt.RequireAuth(), - middleware_cache.Response(cache.Clients[cache.RespCache], 1*time.Minute), + middleware_cache.Response(cache.Clients[cache.RespCache], 1*time.Minute, nil), user_service.ProfileMe, ) } diff --git a/module/util/url.go b/module/util/url.go new file mode 100644 index 0000000..d3e1da8 --- /dev/null +++ b/module/util/url.go @@ -0,0 +1,29 @@ +package util + +import ( + "github.com/TensoRaws/NuxBT-Backend/module/log" + "net/url" +) + +// RemoveQueryParameter 从 URL 中移除一组 query 参数 +func RemoveQueryParameter(rawurl string, keys ...string) string { + u, err := url.Parse(rawurl) + if err != nil { + // 处理错误 + log.Logger.Error("failed to parse url: " + err.Error()) + return rawurl + } + + // 获取原始的 query 参数 + query := u.Query() + + // 删除 query 参数 + for _, key := range keys { + query.Del(key) + } + + // 重新设置 URL 的 query 参数 + u.RawQuery = query.Encode() + + return u.String() +} diff --git a/module/util/url_test.go b/module/util/url_test.go new file mode 100644 index 0000000..5324eea --- /dev/null +++ b/module/util/url_test.go @@ -0,0 +1,52 @@ +package util + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestRemoveQueryParameter(t *testing.T) { + type args struct { + rawurl string + keys []string + } + tests := []struct { + name string + args args + want string + }{ + // TODO: Add test cases. + { + name: "case1", + args: args{rawurl: "http://127.0.0.1:7312/?a=1&b=2&c=3", keys: []string{"a", "b"}}, + want: "http://127.0.0.1:7312/?c=3", + }, + { + name: "case2", + args: args{rawurl: "https://114514.com/?a=1&b=2&c=3", keys: []string{"a", "b", "c"}}, + want: "https://114514.com/", + }, + { + name: "mock token", + args: args{rawurl: "https://114514.com/?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey&sort=top", + keys: []string{"token"}}, + want: "https://114514.com/?sort=top", + }, + { + name: "invalid query", + args: args{rawurl: "https://114514.com/?a=1&b=2&c=3", keys: []string{"d"}}, + want: "https://114514.com/?a=1&b=2&c=3", + }, + { + name: "empty query", + args: args{rawurl: "https://114514.com/?token=eyJhb", keys: []string{}}, + want: "https://114514.com/?token=eyJhb", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, RemoveQueryParameter(tt.args.rawurl, tt.args.keys...), + "RemoveQueryParameter(%v, %v)", tt.args.rawurl, tt.args.keys) + }) + } +} diff --git a/third_party/README.md b/third_party/README.md new file mode 100644 index 0000000..005faa2 --- /dev/null +++ b/third_party/README.md @@ -0,0 +1 @@ +# third_party diff --git a/third_party/gin_cache/LICENSE b/third_party/gin_cache/LICENSE new file mode 100644 index 0000000..0eff00c --- /dev/null +++ b/third_party/gin_cache/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 cyhone + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/gin_cache/cache.go b/third_party/gin_cache/cache.go new file mode 100644 index 0000000..0294570 --- /dev/null +++ b/third_party/gin_cache/cache.go @@ -0,0 +1,279 @@ +package gin_cache + +import ( + "bytes" + "encoding/gob" + "errors" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/TensoRaws/NuxBT-Backend/third_party/gin_cache/persist" + "github.com/gin-gonic/gin" + "golang.org/x/sync/singleflight" +) + +// Strategy the cache strategy +type Strategy struct { + CacheKey string + + // CacheStore if nil, use default cache store instead + CacheStore persist.CacheStore + + // CacheDuration + CacheDuration time.Duration +} + +// GetCacheStrategyByRequest User can this function to design custom cache strategy by request. +// The first return value bool means whether this request should be cached. +// The second return value Strategy determine the special strategy by this request. +type GetCacheStrategyByRequest func(c *gin.Context) (bool, Strategy) + +// Cache user must pass getCacheKey to describe the way to generate cache key +func Cache( + defaultCacheStore persist.CacheStore, + defaultExpire time.Duration, + opts ...Option, +) gin.HandlerFunc { + cfg := newConfigByOpts(opts...) + return cache(defaultCacheStore, defaultExpire, cfg) +} + +func cache( + defaultCacheStore persist.CacheStore, + defaultExpire time.Duration, + cfg *Config, +) gin.HandlerFunc { + if cfg.getCacheStrategyByRequest == nil { + panic("cache strategy is nil") + } + + sfGroup := singleflight.Group{} + + return func(c *gin.Context) { + shouldCache, cacheStrategy := cfg.getCacheStrategyByRequest(c) + if !shouldCache { + c.Next() + return + } + + cacheKey := cacheStrategy.CacheKey + + if cfg.prefixKey != "" { + cacheKey = cfg.prefixKey + cacheKey + } + + // merge cfg + cacheStore := defaultCacheStore + if cacheStrategy.CacheStore != nil { + cacheStore = cacheStrategy.CacheStore + } + + cacheDuration := defaultExpire + if cacheStrategy.CacheDuration > 0 { + cacheDuration = cacheStrategy.CacheDuration + } + + // read cache first + { + respCache := &ResponseCache{} + err := cacheStore.Get(cacheKey, &respCache) + if err == nil { + replyWithCache(c, cfg, respCache) + cfg.hitCacheCallback(c) + return + } + + if !errors.Is(err, persist.ErrCacheMiss) { + cfg.logger.Errorf("get cache error: %s, cache key: %s", err, cacheKey) + } + cfg.missCacheCallback(c) + } + + // cache miss, then call the backend + + // use responseCacheWriter in order to record the response + cacheWriter := &responseCacheWriter{ + ResponseWriter: c.Writer, + } + c.Writer = cacheWriter + + inFlight := false + rawRespCache, _, _ := sfGroup.Do(cacheKey, func() (interface{}, error) { + if cfg.singleFlightForgetTimeout > 0 { + forgetTimer := time.AfterFunc(cfg.singleFlightForgetTimeout, func() { + sfGroup.Forget(cacheKey) + }) + defer forgetTimer.Stop() + } + + c.Next() + + inFlight = true + + respCache := &ResponseCache{} + respCache.fillWithCacheWriter(cacheWriter, cfg) + + // only cache 2xx response + if !c.IsAborted() && cacheWriter.Status() < 300 && cacheWriter.Status() >= 200 { + if err := cacheStore.Set(cacheKey, respCache, cacheDuration); err != nil { + cfg.logger.Errorf("set cache key error: %s, cache key: %s", err, cacheKey) + } + } + + return respCache, nil + }) + + if !inFlight { + replyWithCache(c, cfg, rawRespCache.(*ResponseCache)) + cfg.shareSingleFlightCallback(c) + } + } +} + +// CacheByRequestURI a shortcut function for caching response by uri +func CacheByRequestURI(defaultCacheStore persist.CacheStore, + defaultExpire time.Duration, + opts ...Option, +) gin.HandlerFunc { + cfg := newConfigByOpts(opts...) + + if cfg.getCacheStrategyByRequest != nil { + return cache(defaultCacheStore, defaultExpire, cfg) + } + + var cacheStrategy GetCacheStrategyByRequest + if cfg.ignoreQueryOrder { + cacheStrategy = func(c *gin.Context) (bool, Strategy) { + newUri, err := getRequestUriIgnoreQueryOrder(c.Request.RequestURI) + if err != nil { + cfg.logger.Errorf("getRequestUriIgnoreQueryOrder error: %s", err) + newUri = c.Request.RequestURI + } + + return true, Strategy{ + CacheKey: newUri, + } + } + } else { + cacheStrategy = func(c *gin.Context) (bool, Strategy) { + return true, Strategy{ + CacheKey: c.Request.RequestURI, + } + } + } + + cfg.getCacheStrategyByRequest = cacheStrategy + + return cache(defaultCacheStore, defaultExpire, cfg) +} + +func getRequestUriIgnoreQueryOrder(requestURI string) (string, error) { + parsedUrl, err := url.ParseRequestURI(requestURI) + if err != nil { + return "", err + } + + values := parsedUrl.Query() + + if len(values) == 0 { + return requestURI, nil + } + + queryKeys := make([]string, 0, len(values)) + for queryKey := range values { + queryKeys = append(queryKeys, queryKey) + } + sort.Strings(queryKeys) + + queryVals := make([]string, 0, len(values)) + for _, queryKey := range queryKeys { + sort.Strings(values[queryKey]) + for _, val := range values[queryKey] { + queryVals = append(queryVals, queryKey+"="+val) + } + } + + return parsedUrl.Path + "?" + strings.Join(queryVals, "&"), nil +} + +// CacheByRequestPath a shortcut function for caching response by url path, means will discard the query params +func CacheByRequestPath(defaultCacheStore persist.CacheStore, + defaultExpire time.Duration, + opts ...Option, +) gin.HandlerFunc { + opts = append(opts, WithCacheStrategyByRequest(func(c *gin.Context) (bool, Strategy) { + return true, Strategy{ + CacheKey: c.Request.URL.Path, + } + })) + + return Cache(defaultCacheStore, defaultExpire, opts...) +} + +func init() { + gob.Register(&ResponseCache{}) +} + +// ResponseCache record the http response cache +type ResponseCache struct { + Status int + Header http.Header + Data []byte +} + +func (c *ResponseCache) fillWithCacheWriter(cacheWriter *responseCacheWriter, cfg *Config) { + c.Status = cacheWriter.Status() + c.Data = cacheWriter.body.Bytes() + if !cfg.withoutHeader { + c.Header = cacheWriter.Header().Clone() + + for _, headerKey := range cfg.discardHeaders { + c.Header.Del(headerKey) + } + } +} + +// responseCacheWriter +type responseCacheWriter struct { + gin.ResponseWriter + + body bytes.Buffer +} + +func (w *responseCacheWriter) Write(b []byte) (int, error) { + w.body.Write(b) + return w.ResponseWriter.Write(b) +} + +func (w *responseCacheWriter) WriteString(s string) (int, error) { + w.body.WriteString(s) + return w.ResponseWriter.WriteString(s) +} + +func replyWithCache( + c *gin.Context, + cfg *Config, + respCache *ResponseCache, +) { + cfg.beforeReplyWithCacheCallback(c, respCache) + + c.Writer.WriteHeader(respCache.Status) + + if !cfg.withoutHeader { + for key, values := range respCache.Header { + for _, val := range values { + c.Writer.Header().Set(key, val) + } + } + } + + if _, err := c.Writer.Write(respCache.Data); err != nil { + cfg.logger.Errorf("write response error: %s", err) + } + + // abort handler chain and return directly + c.Abort() +} diff --git a/third_party/gin_cache/cache_test.go b/third_party/gin_cache/cache_test.go new file mode 100644 index 0000000..a15bf3e --- /dev/null +++ b/third_party/gin_cache/cache_test.go @@ -0,0 +1,320 @@ +package gin_cache + +import ( + "fmt" + "math/rand" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/TensoRaws/NuxBT-Backend/third_party/gin_cache/persist" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func mockHttpRequest(middleware gin.HandlerFunc, url string, withRand bool) *httptest.ResponseRecorder { + testWriter := httptest.NewRecorder() + + _, engine := gin.CreateTestContext(testWriter) + engine.Use(middleware) + engine.GET("/cache", func(c *gin.Context) { + body := "uid:" + c.Query("uid") + if withRand { + body += fmt.Sprintf(",rand:%d", rand.Int()) + } + c.String(http.StatusOK, body) + }) + + testRequest := httptest.NewRequest(http.MethodGet, url, nil) + + engine.ServeHTTP(testWriter, testRequest) + + return testWriter +} + +func TestCacheByRequestPath(t *testing.T) { + memoryStore := persist.NewMemoryStore(1 * time.Minute) + cachePathMiddleware := CacheByRequestPath(memoryStore, 3*time.Second) + + w1 := mockHttpRequest(cachePathMiddleware, "/cache?uid=u1", true) + w2 := mockHttpRequest(cachePathMiddleware, "/cache?uid=u2", true) + w3 := mockHttpRequest(cachePathMiddleware, "/cache?uid=u3", true) + + assert.NotEqual(t, w1.Body, "") + assert.Equal(t, w1.Body, w2.Body) + assert.Equal(t, w2.Body, w3.Body) + assert.Equal(t, w1.Code, w2.Code) +} + +func TestCacheHitMissCallback(t *testing.T) { + var cacheHitCount, cacheMissCount int32 + memoryStore := persist.NewMemoryStore(1 * time.Minute) + cachePathMiddleware := CacheByRequestPath(memoryStore, 3*time.Second, + WithOnHitCache(func(c *gin.Context) { + atomic.AddInt32(&cacheHitCount, 1) + }), + WithOnMissCache(func(c *gin.Context) { + atomic.AddInt32(&cacheMissCount, 1) + }), + ) + + mockHttpRequest(cachePathMiddleware, "/cache?uid=u1", true) + mockHttpRequest(cachePathMiddleware, "/cache?uid=u2", true) + mockHttpRequest(cachePathMiddleware, "/cache?uid=u3", true) + + assert.Equal(t, cacheHitCount, int32(2)) + assert.Equal(t, cacheMissCount, int32(1)) +} + +func TestCacheDuration(t *testing.T) { + memoryStore := persist.NewMemoryStore(1 * time.Minute) + cacheURIMiddleware := CacheByRequestURI(memoryStore, 3*time.Second) + + w1 := mockHttpRequest(cacheURIMiddleware, "/cache?uid=u1", true) + time.Sleep(1 * time.Second) + + w2 := mockHttpRequest(cacheURIMiddleware, "/cache?uid=u1", true) + assert.Equal(t, w1.Body, w2.Body) + assert.Equal(t, w1.Code, w2.Code) + time.Sleep(2 * time.Second) + + w3 := mockHttpRequest(cacheURIMiddleware, "/cache?uid=u1", true) + assert.NotEqual(t, w1.Body, w3.Body) +} + +func TestCacheByRequestURI(t *testing.T) { + memoryStore := persist.NewMemoryStore(1 * time.Minute) + cacheURIMiddleware := CacheByRequestURI(memoryStore, 3*time.Second) + + w1 := mockHttpRequest(cacheURIMiddleware, "/cache?uid=u1", true) + w2 := mockHttpRequest(cacheURIMiddleware, "/cache?uid=u1", true) + w3 := mockHttpRequest(cacheURIMiddleware, "/cache?uid=u2", true) + + assert.Equal(t, w1.Body, w2.Body) + assert.Equal(t, w1.Code, w2.Code) + + assert.NotEqual(t, w2.Body, w3.Body) + + w4 := mockHttpRequest(cacheURIMiddleware, "/cache?uid=u4", false) + assert.Equal(t, "uid:u4", w4.Body.String()) +} + +func TestHeader(t *testing.T) { + testWriter := httptest.NewRecorder() + + _, engine := gin.CreateTestContext(testWriter) + + memoryStore := persist.NewMemoryStore(1 * time.Minute) + cacheURIMiddleware := CacheByRequestURI(memoryStore, 3*time.Second) + + engine.Use(func(c *gin.Context) { + c.Header("test_header_key", "test_header_value") + }) + + engine.Use(cacheURIMiddleware) + + engine.GET("/cache", func(c *gin.Context) { + c.Header("test_header_key", "test_header_value2") + c.String(http.StatusOK, "value") + }) + + testRequest := httptest.NewRequest(http.MethodGet, "/cache", nil) + + { + engine.ServeHTTP(testWriter, testRequest) + value := testWriter.Header().Get("test_header_key") + assert.Equal(t, "test_header_value2", value) + } + + { + engine.ServeHTTP(testWriter, testRequest) + value := testWriter.Header().Get("test_header_key") + assert.Equal(t, "test_header_value2", value) + } +} + +func TestConcurrentRequest(t *testing.T) { + memoryStore := persist.NewMemoryStore(1 * time.Minute) + cacheURIMiddleware := CacheByRequestURI(memoryStore, 1*time.Second) + + wg := sync.WaitGroup{} + for i := 0; i < 1000; i++ { + wg.Add(1) + + go func() { + defer wg.Done() + uid := rand.Intn(5) + url := fmt.Sprintf("/cache?uid=%d", uid) + expect := fmt.Sprintf("uid:%d", uid) + + writer := mockHttpRequest(cacheURIMiddleware, url, false) + assert.Equal(t, expect, writer.Body.String()) + }() + } + + wg.Wait() +} + +func TestWriteHeader(t *testing.T) { + memoryStore := persist.NewMemoryStore(1 * time.Minute) + cacheURIMiddleware := CacheByRequestURI(memoryStore, 1*time.Second) + + testWriter := httptest.NewRecorder() + + _, engine := gin.CreateTestContext(testWriter) + engine.Use(cacheURIMiddleware) + engine.GET("/cache", func(c *gin.Context) { + c.Writer.WriteHeader(http.StatusOK) + c.Writer.Header().Set("hello", "world") + }) + + { + testRequest := httptest.NewRequest(http.MethodGet, "/cache", nil) + engine.ServeHTTP(testWriter, testRequest) + assert.Equal(t, "world", testWriter.Header().Get("hello")) + } + + { + testRequest := httptest.NewRequest(http.MethodGet, "/cache", nil) + engine.ServeHTTP(testWriter, testRequest) + assert.Equal(t, "world", testWriter.Header().Get("hello")) + } +} + +func TestGetRequestUriIgnoreQueryOrder(t *testing.T) { + val, err := getRequestUriIgnoreQueryOrder("/test?c=3&b=2&a=1") + require.NoError(t, err) + assert.Equal(t, "/test?a=1&b=2&c=3", val) + + val, err = getRequestUriIgnoreQueryOrder("/test?d=4&e=5") + require.NoError(t, err) + assert.Equal(t, "/test?d=4&e=5", val) +} + +func TestCacheByRequestURIIgnoreOrder(t *testing.T) { + memoryStore := persist.NewMemoryStore(1 * time.Minute) + cacheURIMiddleware := CacheByRequestURI(memoryStore, 3*time.Second, IgnoreQueryOrder()) + + w1 := mockHttpRequest(cacheURIMiddleware, "/cache?uid=u1&a=2", true) + w2 := mockHttpRequest(cacheURIMiddleware, "/cache?a=2&uid=u1", true) + + assert.Equal(t, w1.Body, w2.Body) + assert.Equal(t, w1.Code, w2.Code) + + // test array query param + w3 := mockHttpRequest(cacheURIMiddleware, "/cache?a=2&uid=u1&ids=1&ids=2", true) + w4 := mockHttpRequest(cacheURIMiddleware, "/cache?uid=u1&a=2&ids=2&ids=1", true) + + assert.Equal(t, w3.Body, w4.Body) + assert.Equal(t, w3.Code, w4.Code) + assert.NotEqual(t, w3.Body, w1.Body) +} + +const prefixKey = "#prefix#" + +func TestPrefixKey(t *testing.T) { + memoryStore := persist.NewMemoryStore(1 * time.Minute) + cachePathMiddleware := CacheByRequestPath( + memoryStore, + 3*time.Second, + WithPrefixKey(prefixKey), + ) + + requestPath := "/cache" + + w1 := mockHttpRequest(cachePathMiddleware, requestPath, true) + + err := memoryStore.Delete(prefixKey + requestPath) + require.NoError(t, err) + + w2 := mockHttpRequest(cachePathMiddleware, requestPath, true) + assert.NotEqual(t, w1.Body, w2.Body) +} + +func TestWithDiscardHeaders(t *testing.T) { + const headerKey = "RandKey" + + memoryStore := persist.NewMemoryStore(1 * time.Minute) + cachePathMiddleware := CacheByRequestPath( + memoryStore, + 3*time.Second, + WithDiscardHeaders([]string{ + headerKey, + }), + ) + + _, engine := gin.CreateTestContext(httptest.NewRecorder()) + + engine.GET("/cache", cachePathMiddleware, func(c *gin.Context) { + c.Header(headerKey, fmt.Sprintf("rand:%d", rand.Int())) + c.String(http.StatusOK, "value") + }) + + testRequest := httptest.NewRequest(http.MethodGet, "/cache", nil) + + { + testWriter := httptest.NewRecorder() + engine.ServeHTTP(testWriter, testRequest) + headers1 := testWriter.Header() + assert.NotEqual(t, headers1.Get(headerKey), "") + } + + { + testWriter := httptest.NewRecorder() + engine.ServeHTTP(testWriter, testRequest) + headers2 := testWriter.Header() + assert.Equal(t, headers2.Get(headerKey), "") + } +} + +func TestCustomCacheStrategy(t *testing.T) { + memoryStore := persist.NewMemoryStore(1 * time.Minute) + cacheMiddleware := Cache( + memoryStore, + 24*time.Hour, + WithCacheStrategyByRequest(func(c *gin.Context) (bool, Strategy) { + return true, Strategy{ + CacheKey: "custom_cache_key_" + c.Query("uid"), + } + }), + ) + + _ = mockHttpRequest(cacheMiddleware, "/cache?uid=1", false) + + var val interface{} + err := memoryStore.Get("custom_cache_key_1", &val) + assert.Nil(t, err) +} + +func TestCacheByRequestURICustomCacheStrategy(t *testing.T) { + const customKey = "CustomKey" + memoryStore := persist.NewMemoryStore(1 * time.Minute) + cacheURIMiddleware := CacheByRequestURI(memoryStore, 1*time.Second, WithCacheStrategyByRequest(func(c *gin.Context) (bool, Strategy) { + return true, Strategy{ + CacheKey: customKey, + CacheDuration: 2 * time.Second, + } + })) + + w1 := mockHttpRequest(cacheURIMiddleware, "/cache?uid=u1", true) + var val interface{} + err := memoryStore.Get(customKey, &val) + assert.Nil(t, err) + time.Sleep(1 * time.Second) + + w2 := mockHttpRequest(cacheURIMiddleware, "/cache?uid=u1", true) + assert.Equal(t, w1.Body, w2.Body) + assert.Equal(t, w1.Code, w2.Code) + time.Sleep(3 * time.Second) + + w3 := mockHttpRequest(cacheURIMiddleware, "/cache?uid=u1", true) + assert.NotEqual(t, w1.Body, w3.Body) +} diff --git a/third_party/gin_cache/option.go b/third_party/gin_cache/option.go new file mode 100644 index 0000000..63ff791 --- /dev/null +++ b/third_party/gin_cache/option.go @@ -0,0 +1,177 @@ +package gin_cache + +import ( + "time" + + "github.com/gin-gonic/gin" +) + +// Config contains all options +type Config struct { + logger Logger + + getCacheStrategyByRequest GetCacheStrategyByRequest + + hitCacheCallback OnHitCacheCallback + missCacheCallback OnMissCacheCallback + + beforeReplyWithCacheCallback BeforeReplyWithCacheCallback + + singleFlightForgetTimeout time.Duration + shareSingleFlightCallback OnShareSingleFlightCallback + + ignoreQueryOrder bool + prefixKey string + withoutHeader bool + discardHeaders []string +} + +func newConfigByOpts(opts ...Option) *Config { + cfg := &Config{ + logger: Discard{}, + hitCacheCallback: defaultHitCacheCallback, + missCacheCallback: defaultMissCacheCallback, + beforeReplyWithCacheCallback: defaultBeforeReplyWithCacheCallback, + shareSingleFlightCallback: defaultShareSingleFlightCallback, + } + + for _, opt := range opts { + opt(cfg) + } + + return cfg +} + +// Option represents the optional function. +type Option func(c *Config) + +// WithLogger set the custom logger +func WithLogger(l Logger) Option { + return func(c *Config) { + if l != nil { + c.logger = l + } + } +} + +// Logger define the logger interface +type Logger interface { + Errorf(string, ...interface{}) +} + +// Discard the default logger that will discard all logs of gin_cache +type Discard struct { +} + +// Errorf will output the log at error level +func (l Discard) Errorf(string, ...interface{}) { +} + +// WithCacheStrategyByRequest set up the custom strategy by per request +func WithCacheStrategyByRequest(getGetCacheStrategyByRequest GetCacheStrategyByRequest) Option { + return func(c *Config) { + if getGetCacheStrategyByRequest != nil { + c.getCacheStrategyByRequest = getGetCacheStrategyByRequest + } + } +} + +// OnHitCacheCallback define the callback when use cache +type OnHitCacheCallback func(c *gin.Context) + +var defaultHitCacheCallback = func(c *gin.Context) {} + +// WithOnHitCache will be called when cache hit. +func WithOnHitCache(cb OnHitCacheCallback) Option { + return func(c *Config) { + if cb != nil { + c.hitCacheCallback = cb + } + } +} + +// OnMissCacheCallback define the callback when use cache +type OnMissCacheCallback func(c *gin.Context) + +var defaultMissCacheCallback = func(c *gin.Context) {} + +// WithOnMissCache will be called when cache miss. +func WithOnMissCache(cb OnMissCacheCallback) Option { + return func(c *Config) { + if cb != nil { + c.missCacheCallback = cb + } + } +} + +type BeforeReplyWithCacheCallback func(c *gin.Context, cache *ResponseCache) + +var defaultBeforeReplyWithCacheCallback = func(c *gin.Context, cache *ResponseCache) {} + +// WithBeforeReplyWithCache will be called before replying with cache. +func WithBeforeReplyWithCache(cb BeforeReplyWithCacheCallback) Option { + return func(c *Config) { + if cb != nil { + c.beforeReplyWithCacheCallback = cb + } + } +} + +// OnShareSingleFlightCallback define the callback when share the singleflight result +type OnShareSingleFlightCallback func(c *gin.Context) + +var defaultShareSingleFlightCallback = func(c *gin.Context) {} + +// WithOnShareSingleFlight will be called when share the singleflight result +func WithOnShareSingleFlight(cb OnShareSingleFlightCallback) Option { + return func(c *Config) { + if cb != nil { + c.shareSingleFlightCallback = cb + } + } +} + +// WithSingleFlightForgetTimeout to reduce the impact of long tail requests. +// singleflight.Forget will be called after the timeout has reached for each backend request when timeout is greater than zero. +func WithSingleFlightForgetTimeout(forgetTimeout time.Duration) Option { + return func(c *Config) { + if forgetTimeout > 0 { + c.singleFlightForgetTimeout = forgetTimeout + } + } +} + +// IgnoreQueryOrder will ignore the queries order in url when generate cache key . This option only takes effect in CacheByRequestURI function +func IgnoreQueryOrder() Option { + return func(c *Config) { + c.ignoreQueryOrder = true + } +} + +// WithPrefixKey will prefix the key +func WithPrefixKey(prefix string) Option { + return func(c *Config) { + c.prefixKey = prefix + } +} + +func WithoutHeader() Option { + return func(c *Config) { + c.withoutHeader = true + } +} + +func WithDiscardHeaders(headers []string) Option { + return func(c *Config) { + c.discardHeaders = headers + } +} + +func CorsHeaders() []string { + return []string{ + "Access-Control-Allow-Credentials", + "Access-Control-Expose-Headers", + "Access-Control-Allow-Origin", + "Vary", + } +} diff --git a/third_party/gin_cache/persist/cache.go b/third_party/gin_cache/persist/cache.go new file mode 100644 index 0000000..93fcfaa --- /dev/null +++ b/third_party/gin_cache/persist/cache.go @@ -0,0 +1,21 @@ +package persist + +import ( + "errors" + "time" +) + +// ErrCacheMiss represent the cache key does not exist in the store +var ErrCacheMiss = errors.New("persist cache miss error") + +// CacheStore is the interface of a Cache backend +type CacheStore interface { + // Get retrieves an item from the Cache. if key does not exist in the store, return ErrCacheMiss + Get(key string, value interface{}) error + + // Set sets an item to the Cache, replacing any existing item. + Set(key string, value interface{}, expire time.Duration) error + + // Delete removes an item from the Cache. Does nothing if the key is not in the Cache. + Delete(key string) error +} diff --git a/third_party/gin_cache/persist/codec.go b/third_party/gin_cache/persist/codec.go new file mode 100644 index 0000000..b89280a --- /dev/null +++ b/third_party/gin_cache/persist/codec.go @@ -0,0 +1,21 @@ +package persist + +import ( + "bytes" + "encoding/gob" +) + +// Serialize returns a []byte representing the passed value +func Serialize(value interface{}) ([]byte, error) { + var b bytes.Buffer + encoder := gob.NewEncoder(&b) + if err := encoder.Encode(value); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +// Deserialize will deserialize the passed []byte into the passed ptr interface{} +func Deserialize(payload []byte, ptr interface{}) (err error) { + return gob.NewDecoder(bytes.NewBuffer(payload)).Decode(ptr) +} diff --git a/third_party/gin_cache/persist/codec_test.go b/third_party/gin_cache/persist/codec_test.go new file mode 100644 index 0000000..1b9b5af --- /dev/null +++ b/third_party/gin_cache/persist/codec_test.go @@ -0,0 +1,32 @@ +package persist + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +type testStruct struct { + A int + B string + C *int +} + +func TestCodec(t *testing.T) { + src := &testStruct{ + A: 1, + B: "2", + } + + payload, err := Serialize(src) + require.Nil(t, err) + require.True(t, len(payload) > 0) + + var dest testStruct + err = Deserialize(payload, &dest) + require.Nil(t, err) + + assert.Equal(t, src.A, dest.A) + assert.Equal(t, src.B, dest.B) + assert.Equal(t, src.C, dest.C) +} diff --git a/third_party/gin_cache/persist/memory.go b/third_party/gin_cache/persist/memory.go new file mode 100644 index 0000000..ca54952 --- /dev/null +++ b/third_party/gin_cache/persist/memory.go @@ -0,0 +1,49 @@ +package persist + +import ( + "errors" + "reflect" + "time" + + "github.com/jellydator/ttlcache/v2" +) + +// MemoryStore local memory cache store +type MemoryStore struct { + Cache *ttlcache.Cache +} + +// NewMemoryStore allocate a local memory store with default expiration +func NewMemoryStore(defaultExpiration time.Duration) *MemoryStore { + cacheStore := ttlcache.NewCache() + _ = cacheStore.SetTTL(defaultExpiration) + + // disable SkipTTLExtensionOnHit default + cacheStore.SkipTTLExtensionOnHit(true) + + return &MemoryStore{ + Cache: cacheStore, + } +} + +// Set put key value pair to memory store, and expire after expireDuration +func (c *MemoryStore) Set(key string, value interface{}, expireDuration time.Duration) error { + return c.Cache.SetWithTTL(key, value, expireDuration) +} + +// Delete remove key in memory store, do nothing if key doesn't exist +func (c *MemoryStore) Delete(key string) error { + return c.Cache.Remove(key) +} + +// Get key in memory store, if key doesn't exist, return ErrCacheMiss +func (c *MemoryStore) Get(key string, value interface{}) error { + val, err := c.Cache.Get(key) + if errors.Is(err, ttlcache.ErrNotFound) { + return ErrCacheMiss + } + + v := reflect.ValueOf(value) + v.Elem().Set(reflect.ValueOf(val)) + return nil +} diff --git a/third_party/gin_cache/persist/memory_test.go b/third_party/gin_cache/persist/memory_test.go new file mode 100644 index 0000000..342097f --- /dev/null +++ b/third_party/gin_cache/persist/memory_test.go @@ -0,0 +1,23 @@ +package persist + +import ( + "github.com/stretchr/testify/require" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestMemoryStore(t *testing.T) { + memoryStore := NewMemoryStore(1 * time.Minute) + + expectVal := "123" + require.Nil(t, memoryStore.Set("test", expectVal, 1*time.Second)) + + value := "" + assert.Nil(t, memoryStore.Get("test", &value)) + assert.Equal(t, expectVal, value) + + time.Sleep(1 * time.Second) + assert.Equal(t, ErrCacheMiss, memoryStore.Get("test", &value)) +} diff --git a/third_party/gin_cache/persist/redis.go b/third_party/gin_cache/persist/redis.go new file mode 100644 index 0000000..232132f --- /dev/null +++ b/third_party/gin_cache/persist/redis.go @@ -0,0 +1,53 @@ +package persist + +import ( + "context" + "errors" + "time" + + "github.com/redis/go-redis/v9" +) + +// RedisStore store http response in redis +type RedisStore struct { + RedisClient *redis.Client +} + +// NewRedisStore create a redis memory store with redis client +func NewRedisStore(redisClient *redis.Client) *RedisStore { + return &RedisStore{ + RedisClient: redisClient, + } +} + +// Set put key value pair to redis, and expire after expireDuration +func (store *RedisStore) Set(key string, value interface{}, expire time.Duration) error { + payload, err := Serialize(value) + if err != nil { + return err + } + + ctx := context.TODO() + return store.RedisClient.Set(ctx, key, payload, expire).Err() +} + +// Delete remove key in redis, do nothing if key doesn't exist +func (store *RedisStore) Delete(key string) error { + ctx := context.TODO() + return store.RedisClient.Del(ctx, key).Err() +} + +// Get retrieves an item from redis, if key doesn't exist, return ErrCacheMiss +func (store *RedisStore) Get(key string, value interface{}) error { + ctx := context.TODO() + payload, err := store.RedisClient.Get(ctx, key).Bytes() + + if errors.Is(err, redis.Nil) { + return ErrCacheMiss + } + + if err != nil { + return err + } + return Deserialize(payload, value) +}