diff --git a/.env.example b/.env.example index 2013468..1d1a22a 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ -MYURLS_PORT=8002 +MYURLS_PORT=8080 MYURLS_DOMAIN=example.com -MYURLS_TTL=180 +MYURLS_PROTO=https diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index b7154e8..87a0ca8 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -2,7 +2,8 @@ name: Github CI on: push: - branches: [master] + branches: + - '*' jobs: linux_amd64_build: diff --git a/.gitignore b/.gitignore index 606d02f..6318a54 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ build/ logs/ data/ +*.log .env dist/ +MyUrls diff --git a/Dockerfile b/Dockerfile index 4285311..1d7dcb1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ FROM golang:1.21-alpine AS build -RUN apk update && apk add upx WORKDIR /app -COPY main.go go.mod go.sum . -RUN go mod tidy -RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o myurls main.go \ - && upx myurls +COPY . . + +# RUN go env -w GOPROXY=https://mirrors.cloud.tencent.com/go/,direct +RUN go mod download +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o myurls FROM scratch WORKDIR /app diff --git a/Makefile b/Makefile index db6ebea..bc7e38b 100644 --- a/Makefile +++ b/Makefile @@ -5,41 +5,40 @@ BINARY_DARWIN_ARM64="build/myurls-darwin-arm64" BINARY_WINDOWS="build/myurls-windows-x64" BINARY_ARM64="build/myurls-linux-arm64" -GOFILES="main.go" VERSION=1.0.0 BUILD=`date +%FT%T%z` default: @echo ${BINARY_DEFAULT} - @CGO_ENABLED=0 go build -ldflags="-s -w" -o ${BINARY_DEFAULT} ${GOFILES} + @CGO_ENABLED=0 go build -ldflags="-s -w" -o ${BINARY_DEFAULT} all: @echo ${BINARY_LINUX} - @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ${BINARY_LINUX} ${GOFILES} + @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ${BINARY_LINUX} # @echo ${BINARY_DARWIN} - # @CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o ${BINARY_DARWIN} ${GOFILES} + # @CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o ${BINARY_DARWIN} # @echo ${BINARY_DARWIN_ARM64} - # @CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o ${BINARY_DARWIN_ARM64} ${GOFILES} + # @CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o ${BINARY_DARWIN_ARM64} @echo ${BINARY_WINDOWS} - @CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o ${BINARY_WINDOWS} ${GOFILES} + @CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o ${BINARY_WINDOWS} @echo ${BINARY_ARM64} - @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o ${BINARY_ARM64} ${GOFILES} + @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o ${BINARY_ARM64} linux: @echo ${BINARY_LINUX} - @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ${BINARY_LINUX} ${GOFILES} + @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ${BINARY_LINUX} darwin: @echo ${BINARY_DARWIN} - @CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o ${BINARY_DARWIN} ${GOFILES} + @CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o ${BINARY_DARWIN} windows: @echo ${BINARY_WINDOWS} - @CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o ${BINARY_WINDOWS} ${GOFILES} + @CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o ${BINARY_WINDOWS} aarch64: @echo ${BINARY_ARM64} - @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o ${BINARY_ARM64} ${GOFILES} + @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o ${BINARY_ARM64} install: @go mod tidy diff --git a/README.md b/README.md index e0af06f..25d9cad 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,6 @@ - [Dependencies](#dependencies) - [Docker](#docker) - - [Deploy Online](#deploy-online) - - [Deploy on Railway](#deploy-on-railway) - - [部署](#部署) - - [添加域名](#添加域名) - [Install](#install) - [Usage](#usage) - [日志清理](#日志清理) @@ -35,7 +31,7 @@ sudo apt-get install redis-server -y 现在你可以无需安装其他服务,使用 docker 或 [docker-compose](https://docs.docker.com/compose/install/) 部署本项目。注:请自行修改 .env 中参数。 ``` -docker run -d --restart always --name myurls careywong/myurls:latest -domain example.com -port 8002 -conn 127.0.0.1:6379 -passwd '' -ttl 90 +docker run -d --restart always --name myurls careywong/myurls:latest -domain example.com -port 8002 -conn 127.0.0.1:6379 -password '' ``` ```shell script @@ -46,25 +42,6 @@ cp .env.example .env docker-compose up -d ``` -## Deploy Online - -### Deploy on Railway - -#### 部署 - -[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template?template=https%3A%2F%2Fgithub.com%2Fpzcn%2FMyurls-Railway&plugins=redis&envs=ENV_DOMAIN%2CENV_TTL%2CPORT&ENV_DOMAINDesc=Your+domain.&ENV_TTLDesc=Short+link+validity+period+%28day%29&PORTDesc=DO+NOT+CHANGE&ENV_TTLDefault=180&PORTDefault=80) - -通过上方链接一键部署到Railway,并填入以下参数 - -参数说明: - -- `DOMAIN` - 短链接域名,必填项,不需要添加https:// (如 abc.com) -- `TTL` - 短链接有效期,单位(天),默认180天 (default 180) -- `PORT` - 端口,保持80,请勿修改 - -#### 添加域名 - -在Cloudflare中添加域名,并配置SSL/TLS为完全及以上,并在Railway中接入该域名,参考[官方文档](https://docs.railway.app/deploy/exposing-your-app#lets-encrypt-ssl-certificates)。 ## Install @@ -77,7 +54,7 @@ make install 生成可执行文件,目录位于 build/ 。默认当前平台,其他平台请参照 Makefile 或执行对应 go build 命令。 ```shell script -bash release.sh +make ``` ## Usage @@ -85,17 +62,18 @@ bash release.sh 前往 [Actions](https://github.com/CareyWang/MyUrls/actions/workflows/go.yml) 下载对应平台可执行文件。 ```shell script -Usage: +Usage of ./MyUrls: -conn string - Redis连接,格式: host:port (default "127.0.0.1:6379") + address of the redis server (default "localhost:6379") -domain string - 短链接域名,必填项 - -passwd string - Redis连接密码 - -port int - 服务端口 (default 8002) - -ttl int - 短链接有效期,单位(天),默认180天。 (default 180) + domain of the server (default "localhost:8080") + -h display help + -password string + password of the redis server + -port string + port to run the server on (default "8080") + -proto string + protocol of the server (default "https") ``` 建议配合 [pm2](https://pm2.keymetrics.io/) 开启守护进程。 diff --git a/const.go b/const.go new file mode 100644 index 0000000..5897fb0 --- /dev/null +++ b/const.go @@ -0,0 +1,13 @@ +package main + +type Response struct { + Code int `json:"Code"` + Msg string `json:"Message"` + Data any `json:"Data"` +} + +// Response codes +const ResponseCodeSuccess = 0 // Success +const ResponseCodeSuccessLegacy = 1 // Success +const ResponseCodeParamsCheckError = 1001 // Parameter check error +const ResponseCodeServerError = 1002 // Server error diff --git a/docker-compose.yaml b/docker-compose.yaml index e53eec8..7d8a304 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -6,15 +6,15 @@ services: restart: always env_file: .env ports: - - "${MYURLS_PORT}:8002" + - "${MYURLS_PORT}:8080" volumes: - ./data/myurls/logs:/app/logs depends_on: - myurls-redis - entrypoint: ["/app/myurls", "-domain", "${MYURLS_DOMAIN}", "-conn", myurls-redis:6379, "-ttl", "${MYURLS_TTL}"] + entrypoint: ["/app/myurls", "-domain", "${MYURLS_DOMAIN}", "-conn", myurls-redis:6379] myurls-redis: - image: "redis:6" + image: "redis:7" container_name: myurls-redis restart: always volumes: diff --git a/go.mod b/go.mod index e4efd90..0e87d87 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,22 @@ module github.com/CareyWang/MyUrls -go 1.20 +go 1.21 require ( github.com/gin-gonic/gin v1.9.1 - github.com/gomodule/redigo v1.8.9 + github.com/redis/go-redis/v9 v9.4.0 github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.8.4 + go.uber.org/zap v1.26.0 ) require ( github.com/bytedance/sonic v1.10.2 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect github.com/chenzhuoyu/iasm v0.9.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -26,8 +31,10 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.7.0 // indirect golang.org/x/crypto v0.18.0 // indirect golang.org/x/net v0.20.0 // indirect diff --git a/go.sum b/go.sum index 800b9e8..e850c22 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,13 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE= github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= @@ -13,6 +19,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -20,6 +28,7 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -28,9 +37,8 @@ github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws= -github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= @@ -59,6 +67,8 @@ github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOS github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk= +github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -77,6 +87,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc= golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= @@ -92,6 +108,7 @@ golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..49def95 --- /dev/null +++ b/handlers.go @@ -0,0 +1,112 @@ +package main + +import ( + "encoding/base64" + "time" + + "github.com/gin-gonic/gin" +) + +const defaultTTL = time.Hour * 24 * 365 // 默认过期时间,1年 +const defaultRenewTime = time.Hour * 48 // 默认续命时间,2天 +const defaultShortKeyLength = 7 // 默认短链接长度,7位 + +// ShortToLongHandler gets the long URL from a short URL +func ShortToLongHandler() gin.HandlerFunc { + return func(c *gin.Context) { + resp := Response{} + shortKey := c.Param("shortKey") + longURL := ShortToLong(c, shortKey) + if longURL == "" { + resp.Code = ResponseCodeServerError + resp.Msg = "failed to get long URL, please check the short URL if exists or expired" + + c.JSON(404, resp) + return + } + + // todo + // check whether need renew expiration time + // only renew once per day + // if err := Renew(c, shortKey, defaultRenewTime); err != nil { + // logger.Warn("failed to renew short URL: ", err.Error()) + // } + + c.Redirect(301, longURL) + } +} + +type LongToShortParams struct { + LongUrl string `form:"longUrl" binding:"required"` + ShortKey string `form:"shortKey"` +} + +// LongToShortHandler creates a short URL from a long URL +func LongToShortHandler() gin.HandlerFunc { + return func(c *gin.Context) { + resp := Response{} + + // check parameters + req := LongToShortParams{} + if err := c.ShouldBind(&req); err != nil { + resp.Code = ResponseCodeParamsCheckError + resp.Msg = "invalid parameters" + logger.Warn("invalid parameters: ", err.Error()) + + c.JSON(200, resp) + return + } + + // 兼容以前的实现,这里如果是 base64 编码的字符串,进行解码 + _longUrl, err := base64.StdEncoding.DecodeString(req.LongUrl) + if err == nil { + req.LongUrl = string(_longUrl) + } + + // generate short key + if req.ShortKey == "" { + req.ShortKey = GenerateRandomString(defaultShortKeyLength) + } + // check whether short key exists + exists, err := CheckRedisKeyIfExist(c, req.ShortKey) + if err != nil { + resp.Code = ResponseCodeServerError + resp.Msg = "failed to check short key" + logger.Error("failed to check short key: ", err.Error()) + + c.JSON(200, resp) + return + } + if exists { + resp.Code = ResponseCodeParamsCheckError + resp.Msg = "short key already exists, please use another one or leave it empty to generate automatically" + + logger.Info("short key already exists: ", req.ShortKey) + c.JSON(200, resp) + return + } + + options := &LongToShortOptions{ + ShortKey: req.ShortKey, + URL: req.LongUrl, + expiration: defaultTTL, + } + if err := LongToShort(c, options); err != nil { + resp.Code = ResponseCodeServerError + resp.Msg = "failed to create short URL" + logger.Warn("failed to create short URL: ", err.Error()) + + c.JSON(200, resp) + return + } + + shortURL := proto + "://" + domain + "/" + options.ShortKey + + // 兼容以前的返回结构体 + respDataLegacy := gin.H{ + "Code": ResponseCodeSuccessLegacy, + "ShortUrl": shortURL, + } + c.JSON(200, respDataLegacy) + } +} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..c4f2827 --- /dev/null +++ b/logger.go @@ -0,0 +1,142 @@ +package main + +import ( + "fmt" + "os" + "path" + "time" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +var logger *zap.SugaredLogger + +func InitLogger() { + // 创建 logs 目录 + if dir, err := os.Getwd(); err == nil { + logFilePath := dir + "/logs/" + if err := os.MkdirAll(logFilePath, 0777); err != nil { + panic("create log dir failed") + } + } + + // 初始化 zap logger + initZapLogger() +} + +// 定义 gin logger +func initLoggerForGin() *logrus.Logger { + logFilePath := "" + if dir, err := os.Getwd(); err == nil { + logFilePath = dir + "/logs/" + } + if err := os.MkdirAll(logFilePath, 0777); err != nil { + fmt.Println(err.Error()) + } + logFileName := "access.log" + + // 日志文件 + fileName := path.Join(logFilePath, logFileName) + if _, err := os.Stat(fileName); err != nil { + if _, err := os.Create(fileName); err != nil { + panic("create log file failed") + } + } + + // 写入文件 + src, err := os.OpenFile(fileName, os.O_APPEND|os.O_WRONLY, os.ModeAppend) + if err != nil { + fmt.Println("err", err) + } + + // 实例化 + _logger := logrus.New() + + // 设置输出 + _logger.SetOutput(src) + // logger.Out = src + + // 设置日志级别 + _logger.SetLevel(logrus.DebugLevel) + + // 设置日志格式 + _logger.Formatter = &logrus.JSONFormatter{} + + return _logger +} + +// gin 文件日志 +func LoggerToFile() gin.HandlerFunc { + _logger := initLoggerForGin() + return func(c *gin.Context) { + logMap := make(map[string]any) + + // 开始时间 + startTime := time.Now() + logMap["startTime"] = startTime.Format("2006-01-02 15:04:05") + + // 处理请求 + c.Next() + + // 结束时间 + endTime := time.Now() + logMap["endTime"] = endTime.Format("2006-01-02 15:04:05") + + // 执行时间 + logMap["latencyTime"] = endTime.Sub(startTime).Microseconds() + + // 请求方式 + logMap["reqMethod"] = c.Request.Method + + // 请求路由 + logMap["reqUri"] = c.Request.RequestURI + + // 状态码 + logMap["statusCode"] = c.Writer.Status() + + // 请求IP + logMap["clientIP"] = c.ClientIP() + + // 请求 UA + logMap["clientUA"] = c.Request.UserAgent() + + // 日志格式 + // logJson, _ := json.Marshal(logMap) + // _logger.Info(string(logJson)) + + _logger.WithFields(logrus.Fields{ + "startTime": logMap["startTime"], + "endTime": logMap["endTime"], + "latencyTime": logMap["latencyTime"], + "reqMethod": logMap["reqMethod"], + "reqUri": logMap["reqUri"], + "statusCode": logMap["statusCode"], + "clientIP": logMap["clientIP"], + "clientUA": logMap["clientUA"], + }).Info() + } +} + +// 定义 zap logger +func initZapLogger() { + writeSyncer := getLogWriter() + encoder := getEncoder() + core := zapcore.NewCore(encoder, writeSyncer, zapcore.DebugLevel) + + _logger := zap.New(core) + defer _logger.Sync() + + logger = _logger.Sugar() +} + +func getEncoder() zapcore.Encoder { + return zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()) +} + +func getLogWriter() zapcore.WriteSyncer { + file, _ := os.Create("./logs/runtime.log") + return zapcore.AddSync(file) +} diff --git a/logic.go b/logic.go new file mode 100644 index 0000000..e8d3c59 --- /dev/null +++ b/logic.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "time" +) + +// ShortToLong gets the long URL from a short URL +func ShortToLong(ctx context.Context, shortKey string) string { + rc := GetRedisClient() + return rc.Get(ctx, shortKey).Val() +} + +// LongToShortOptions are the options for the LongToShort function +type LongToShortOptions struct { + ShortKey string + URL string + expiration time.Duration +} + +// LongToShort creates a short URL from a long URL +func LongToShort(ctx context.Context, options *LongToShortOptions) error { + rc := GetRedisClient() + return rc.SetEx(ctx, options.ShortKey, options.URL, options.expiration).Err() +} + +// Renew updates the expiration time of a short URL +func Renew(ctx context.Context, shortKey string, expiration time.Duration) error { + rc := GetRedisClient() + + rs := rc.TTL(ctx, shortKey) + if rs.Err() != nil { + return rs.Err() + } + + ttl := rs.Val() + if ttl < 0 { + return nil + } + + return rc.Expire(ctx, shortKey, ttl+expiration).Err() +} + +func CheckRedisKeyIfExist(ctx context.Context, key string) (bool, error) { + rc := GetRedisClient() + rs := rc.Exists(ctx, key) + if rs.Err() != nil { + return false, rs.Err() + } + + return rs.Val() > 0, nil +} diff --git a/logic_test.go b/logic_test.go new file mode 100644 index 0000000..4a32e01 --- /dev/null +++ b/logic_test.go @@ -0,0 +1,31 @@ +// FILEPATH: /root/CareyWang/MyUrls/logic_test.go + +package main + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestLongToShortAndShortToLong(t *testing.T) { + ctx := context.Background() + initRedisClient(mockRedisOptions) + + shortKey := "testKey" + longURL := "https://example.com" + + err := LongToShort(ctx, &LongToShortOptions{ + ShortKey: shortKey, + URL: longURL, + expiration: 60 * time.Second, + }) + assert.NoError(t, err) + // delete test data from redis + defer GetRedisClient().Del(ctx, shortKey) + + resultLongURL := ShortToLong(ctx, shortKey) + assert.Equal(t, longURL, resultLongURL) +} diff --git a/main.go b/main.go index 07571b6..d34954d 100644 --- a/main.go +++ b/main.go @@ -1,391 +1,111 @@ package main import ( - "crypto/md5" - "encoding/base64" - "encoding/hex" + "context" "flag" "fmt" - "log" - "math/rand" "net/http" "os" - "path" - "time" "github.com/gin-gonic/gin" - "github.com/gomodule/redigo/redis" - "github.com/sirupsen/logrus" + "github.com/redis/go-redis/v9" ) -// Response is the response structure -type Response struct { - Code int - Message string - LongUrl string - ShortUrl string +var helpFlag bool + +var ( + port = "8080" + domain = "localhost:8080" + proto = "https" + redisAddr = "localhost:6379" + redisPassword = "" +) + +func init() { + flag.BoolVar(&helpFlag, "h", false, "display help") + + flag.StringVar(&port, "port", port, "port to run the server on") + flag.StringVar(&domain, "domain", domain, "domain of the server") + flag.StringVar(&proto, "proto", proto, "protocol of the server") + flag.StringVar(&redisAddr, "conn", redisAddr, "address of the redis server") + flag.StringVar(&redisPassword, "password", redisPassword, "password of the redis server") } -// redisPoolConf is the Redis pool configuration. -type redisPoolConf struct { - maxIdle int - maxActive int - maxIdleTimeout int - host string - password string - db int - handleTimeout int -} - -// letterBytes is a string containing all the characters used in the short URL generation. -const letterBytes = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - -// shortUrlLen is the length of the generated short URL. -const shortUrlLen = 7 - -// defaultPort is the default port number. -const defaultPort int = 8002 - -// defaultExpire is the redis ttl in days for a short URL. -const defaultExpire = 180 - -// defaultRedisConfig is the default Redis configuration. -const defaultRedisConfig = "127.0.0.1:6379" - -// defaultLockPrefix is the default prefix for Redis locks. -const defaultLockPrefix = "myurls:lock:" - -// defaultRenewal is the default renewal time for Redis locks. -const defaultRenewal = 1 - -// secondsPerDay is the number of seconds in a day. -const secondsPerDay = 24 * 3600 - -// redisPool is a connection pool for Redis. -var redisPool *redis.Pool - -// redisPoolConfig is the Redis pool configuration. -var redisPoolConfig *redisPoolConf - -// redisClient is a Redis client. -var redisClient redis.Conn - func main() { + flag.Parse() + if helpFlag { + flag.Usage() + os.Exit(0) + } + + // 从环境变量中读取配置,且环境变量优先级高于命令行参数 + parseEnvirons() + + InitLogger() + + // init and check redis + initRedisClient(&redis.Options{ + Addr: redisAddr, + Password: redisPassword, + DB: 0, + }) + + ctx := context.Background() + rc := GetRedisClient() + rs := rc.Ping(ctx) + if rs.Err() != nil { + logger.Fatalln("redis ping failed: ", rs.Err()) + } + logger.Info("redis ping success") + + // GC optimize + ballast := make([]byte, 1<<30) // 预分配 1G 内存,不会实际占用物理内存,不可读写该变量 + defer func() { + logger.Info("ballast len %v", len(ballast)) + }() + + // start http server + run() +} + +func parseEnvirons() { + if p := os.Getenv("MYURLS_PORT"); p != "" { + port = p + } + if d := os.Getenv("MYURLS_DOMAIN"); d != "" { + domain = d + } + if p := os.Getenv("MYURLS_PROTO"); p != "" { + proto = p + } + if c := os.Getenv("MYURLS_REDIS_CONN"); c != "" { + redisAddr = c + } + if p := os.Getenv("MYURLS_REDIS_PASSWORD"); p != "" { + redisPassword = p + } +} + +func run() { + // init and run server gin.SetMode(gin.ReleaseMode) router := gin.Default() - // Log 收集中间件 + // logger router.Use(LoggerToFile()) + // static files router.LoadHTMLGlob("public/*.html") - port := flag.Int("port", defaultPort, "服务端口") - domain := flag.String("domain", "", "短链接域名,必填项") - ttl := flag.Int("ttl", defaultExpire, "短链接有效期,单位(天),默认180天。") - conn := flag.String("conn", defaultRedisConfig, "Redis连接,格式: host:port") - passwd := flag.String("passwd", "", "Redis连接密码") - https := flag.Int("https", 1, "是否返回 https 短链接") - flag.Parse() - - if *domain == "" { - flag.Usage() - log.Fatalln("缺少关键参数") - } - - redisPoolConfig = &redisPoolConf{ - maxIdle: 1024, - maxActive: 1024, - maxIdleTimeout: 30, - host: *conn, - password: *passwd, - db: 0, - handleTimeout: 30, - } - initRedisPool() - router.GET("/", func(context *gin.Context) { context.HTML(http.StatusOK, "index.html", gin.H{ "title": "MyUrls", }) }) - // 短链接生成 - router.POST("/short", func(context *gin.Context) { - res := &Response{ - Code: 1, - Message: "", - LongUrl: "", - ShortUrl: "", - } + router.POST("/short", LongToShortHandler()) + router.GET("/:shortKey", ShortToLongHandler()) - longUrl := context.PostForm("longUrl") - shortKey := context.PostForm("shortKey") - if longUrl == "" { - res.Code = 0 - res.Message = "longUrl为空" - context.JSON(200, *res) - return - } - - _longUrl, _ := base64.StdEncoding.DecodeString(longUrl) - longUrl = string(_longUrl) - res.LongUrl = longUrl - - // 根据有没有填写 short key,分别执行 - if shortKey != "" { - redisClient := redisPool.Get() - - // 检测短链是否已存在 - _exists, _ := redis.String(redisClient.Do("get", shortKey)) - if _exists != "" && _exists != longUrl { - res.Code = 0 - res.Message = "短链接已存在,请更换key" - context.JSON(200, *res) - return - } - - // 存储 - _, _ = redisClient.Do("set", shortKey, longUrl) - - } else { - shortKey = longToShort(longUrl, *ttl*secondsPerDay) - } - - protocol := "http://" - if *https != 0 { - protocol = "https://" - } - res.ShortUrl = protocol + *domain + "/" + shortKey - - // context.Header("Access-Control-Allow-Origin", "*") - context.JSON(200, *res) - }) - - // 短链接跳转 - router.GET("/:shortKey", func(context *gin.Context) { - shortKey := context.Param("shortKey") - longUrl := shortToLong(shortKey) - - if longUrl == "" { - context.String(http.StatusNotFound, "短链接不存在或已过期") - } else { - context.Redirect(http.StatusMovedPermanently, longUrl) - } - }) - - // GC 优化 - ballast := make([]byte, 1<<30) // 分配 1G 内存,不会实际占用物理内存,不可读写该变量 - defer func() { - log.Println("ballast len %v", len(ballast)) - }() - - router.Run(fmt.Sprintf(":%d", *port)) -} - -// 短链接转长链接 -func shortToLong(shortKey string) string { - redisClient = redisPool.Get() - defer redisClient.Close() - - longUrl, _ := redis.String(redisClient.Do("get", shortKey)) - - // 获取到长链接后,续命1天。每天仅允许续命1次。 - if longUrl != "" { - renew(shortKey) - } - - return longUrl -} - -// 长链接转短链接 -func longToShort(longUrl string, ttl int) string { - redisClient = redisPool.Get() - defer redisClient.Close() - - // 是否生成过该长链接对应短链接 - longUrlMD5Bytes := md5.Sum([]byte(longUrl)) - longUrlMD5 := hex.EncodeToString(longUrlMD5Bytes[:]) - _existsKey, _ := redis.String(redisClient.Do("get", longUrlMD5)) - if _existsKey != "" { - _, _ = redisClient.Do("expire", _existsKey, ttl) - - log.Println("Hit cache: " + _existsKey) - return _existsKey - } - - // 重试三次 - var shortKey string - for i := 0; i < 3; i++ { - shortKey = generate(shortUrlLen) - - _existsLongUrl, _ := redis.String(redisClient.Do("get", shortKey)) - if _existsLongUrl == "" { - break - } - } - - if shortKey != "" { - _, _ = redisClient.Do("mset", shortKey, longUrl, longUrlMD5, shortKey) - - _, _ = redisClient.Do("expire", shortKey, ttl) - _, _ = redisClient.Do("expire", longUrlMD5, secondsPerDay) - } - - return shortKey -} - -// 续命 -func renew(shortKey string) { - redisClient = redisPool.Get() - defer redisClient.Close() - - // 加锁 - lockKey := defaultLockPrefix + shortKey - lock, _ := redis.Int(redisClient.Do("setnx", lockKey, 1)) - if lock == 1 { - // 设置锁过期时间 - _, _ = redisClient.Do("expire", lockKey, defaultRenewal*secondsPerDay) - - // 续命 - ttl, err := redis.Int(redisClient.Do("ttl", shortKey)) - if err == nil && ttl != -1 { - _, _ = redisClient.Do("expire", shortKey, ttl+defaultRenewal*secondsPerDay) - } - } -} - -// generate is a function that takes an integer bits and returns a string. -// The function generates a random string of length equal to bits using the letterBytes slice. -// The letterBytes slice contains characters that can be used to generate a random string. -// The generation of the random string is based on the current time using the UnixNano() function. -func generate(bits int) string { - // Create a byte slice b of length bits. - b := make([]byte, bits) - - // Create a new random number generator with the current time as the seed. - r := rand.New(rand.NewSource(time.Now().UnixNano())) - - // Generate a random byte for each element in the byte slice b using the letterBytes slice. - for i := range b { - b[i] = letterBytes[r.Intn(len(letterBytes))] - } - - // Convert the byte slice to a string and return it. - return string(b) -} - -// 定义 logger -func Logger() *logrus.Logger { - logFilePath := "" - if dir, err := os.Getwd(); err == nil { - logFilePath = dir + "/logs/" - } - if err := os.MkdirAll(logFilePath, 0777); err != nil { - fmt.Println(err.Error()) - } - logFileName := "access.log" - - //日志文件 - fileName := path.Join(logFilePath, logFileName) - if _, err := os.Stat(fileName); err != nil { - if _, err := os.Create(fileName); err != nil { - fmt.Println(err.Error()) - } - } - - //写入文件 - src, err := os.OpenFile(fileName, os.O_APPEND|os.O_WRONLY, os.ModeAppend) - if err != nil { - fmt.Println("err", err) - } - - //实例化 - logger := logrus.New() - - //设置输出 - logger.SetOutput(src) - // logger.Out = src - - //设置日志级别 - logger.SetLevel(logrus.DebugLevel) - - //设置日志格式 - logger.Formatter = &logrus.JSONFormatter{} - - return logger -} - -// 文件日志 -func LoggerToFile() gin.HandlerFunc { - logger := Logger() - return func(c *gin.Context) { - logMap := make(map[string]interface{}) - - // 开始时间 - startTime := time.Now() - logMap["startTime"] = startTime.Format("2006-01-02 15:04:05") - - // 处理请求 - c.Next() - - // 结束时间 - endTime := time.Now() - logMap["endTime"] = endTime.Format("2006-01-02 15:04:05") - - // 执行时间 - logMap["latencyTime"] = endTime.Sub(startTime).Microseconds() - - // 请求方式 - logMap["reqMethod"] = c.Request.Method - - // 请求路由 - logMap["reqUri"] = c.Request.RequestURI - - // 状态码 - logMap["statusCode"] = c.Writer.Status() - - // 请求IP - logMap["clientIP"] = c.ClientIP() - - // 请求 UA - logMap["clientUA"] = c.Request.UserAgent() - - //日志格式 - // logJson, _ := json.Marshal(logMap) - // logger.Info(string(logJson)) - - logger.WithFields(logrus.Fields{ - "startTime": logMap["startTime"], - "endTime": logMap["endTime"], - "latencyTime": logMap["latencyTime"], - "reqMethod": logMap["reqMethod"], - "reqUri": logMap["reqUri"], - "statusCode": logMap["statusCode"], - "clientIP": logMap["clientIP"], - "clientUA": logMap["clientUA"], - }).Info() - } -} - -// redis 连接池 -func initRedisPool() { - // 建立连接池 - redisPool = &redis.Pool{ - MaxIdle: redisPoolConfig.maxIdle, - MaxActive: redisPoolConfig.maxActive, - IdleTimeout: time.Duration(redisPoolConfig.maxIdleTimeout) * time.Second, - Wait: true, - Dial: func() (redis.Conn, error) { - con, err := redis.Dial("tcp", redisPoolConfig.host, - redis.DialPassword(redisPoolConfig.password), - redis.DialDatabase(redisPoolConfig.db), - redis.DialConnectTimeout(time.Duration(redisPoolConfig.handleTimeout)*time.Second), - redis.DialReadTimeout(time.Duration(redisPoolConfig.handleTimeout)*time.Second), - redis.DialWriteTimeout(time.Duration(redisPoolConfig.handleTimeout)*time.Second)) - if err != nil { - return nil, err - } - return con, nil - }, - } + logger.Infof("server running on :%s", port) + router.Run(fmt.Sprintf(":%s", port)) } diff --git a/random.go b/random.go new file mode 100644 index 0000000..adae7ad --- /dev/null +++ b/random.go @@ -0,0 +1,28 @@ +package main + +import ( + "math/rand" + "time" +) + +const letterBytes = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +// generate is a function that takes an integer bits and returns a string. +// The function generates a random string of length equal to bits using the letterBytes slice. +// The letterBytes slice contains characters that can be used to generate a random string. +// The generation of the random string is based on the current time using the UnixNano() function. +func GenerateRandomString(bits int) string { + // Create a byte slice b of length bits. + b := make([]byte, bits) + + // Create a new random number generator with the current time as the seed. + r := rand.New(rand.NewSource(time.Now().UnixNano())) + + // Generate a random byte for each element in the byte slice b using the letterBytes slice. + for i := range b { + b[i] = letterBytes[r.Intn(len(letterBytes))] + } + + // Convert the byte slice to a string and return it. + return string(b) +} diff --git a/redis.go b/redis.go new file mode 100644 index 0000000..cefce34 --- /dev/null +++ b/redis.go @@ -0,0 +1,16 @@ +package main + +import ( + "github.com/redis/go-redis/v9" +) + +var RedisClient *redis.Client + +// initRedisClient is a function that takes a pointer to a RedisOptions struct and returns a pointer to a Redis client. +func initRedisClient(options *redis.Options) { + RedisClient = redis.NewClient(options) +} + +func GetRedisClient() *redis.Client { + return RedisClient +} diff --git a/redis_test.go b/redis_test.go new file mode 100644 index 0000000..e0a9725 --- /dev/null +++ b/redis_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "testing" + + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" +) + +var mockRedisOptions = &redis.Options{ + Addr: "localhost:6379", + Password: "", + DB: 0, +} + +func TestGetRedisClient(t *testing.T) { + client := GetRedisClient() + assert.Nil(t, client) + + initRedisClient(mockRedisOptions) + client = GetRedisClient() + assert.NotNil(t, client) + + // Test redis exec commands and response + ctx := context.Background() + rs := client.Ping(ctx) + assert.Nil(t, rs.Err()) + assert.Equal(t, "PONG", rs.Val()) + + rsCmd := GetRedisClient().Do(ctx, "dbsize") + assert.Nil(t, rsCmd.Err()) +} + +func BenchmarkGetRedisClient(b *testing.B) { + initRedisClient(mockRedisOptions) + b.ResetTimer() + for i := 0; i < b.N; i++ { + GetRedisClient().Get(context.Background(), "key") + } +}