diff --git a/README.md b/README.md index 0d1c381..7025e58 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ gateway/ │ ├── config/ # go-zero RestConf 等服務設定 │ ├── library/errors/ # 全專案唯一結構化錯誤(8 碼 SSCCCDDD) │ ├── library/validate/ # struct 驗證(go-playground/validator + 翻譯) +│ ├── library/mongo/ # DocumentDB + Redis cache(mongo-driver v2) │ ├── library/errlog/ # 可選 slog 日誌輔助 │ └── model/ # 業務模型根目錄(見下方) │ └── {module}/ # 例如 member/、order/ @@ -157,6 +158,7 @@ HTTP Request - [generate/api/README.md](generate/api/README.md) — `.api` 與 `@respdoc` 約定 - [internal/response/README.md](internal/response/README.md) — Handler / Logic 分工 - [internal/library/errors/README.md](internal/library/errors/README.md) — 錯誤碼與 HTTP 對照 +- [internal/library/mongo/README.md](internal/library/mongo/README.md) — MongoDB / Redis cache 流程與用法 - [docs/model.md](docs/model.md) — `internal/model/{module}` 分層(entity / repository / usecase) ## 開發約定 diff --git a/docs/model.md b/docs/model.md index dbec016..decb2a1 100644 --- a/docs/model.md +++ b/docs/model.md @@ -119,7 +119,7 @@ func (p Platform) ToString() string { /* map lookup */ } - 方法第一個參數固定為 `context.Context`。 - 參數 / 回傳值使用 `entity` 型別,不暴露 driver 細節(除 index migration 等必要場景)。 - Index migration 以獨立 interface 嵌入,命名 `{Entity}IndexUP`,方法名含版本號,如 `Index20241226001UP`。 -- 介面檔案不含實作、不含 import 基礎設施 package(`mon`、`mgo` 等僅在實作檔出現)。 +- 介面檔案不含實作、不含 import 基礎設施 package(`mon`、`mongo` 實作層等僅在 repository 實作出現)。 **範例:** @@ -152,7 +152,7 @@ type AccountIndexUP interface { - struct 名稱 `{Entity}Repository`,建構子 `New{Entity}Repository(param {Entity}RepositoryParam)`。 - Param struct 集中注入 `Conf`、`CacheConf`、`DBOpts`、`CacheOpts`。 - 建構時以 entity 的 `CollectionName()` 初始化 DocumentDB;失敗時 `panic`(啟動期錯誤)。 -- CRUD 透過 `mgo.DocumentDBWithCacheUseCase` 操作,搭配模組 `redis.go` 的 key helper。 +- CRUD 透過 `mongo.DocumentDBWithCacheUseCase`(`gateway/internal/library/mongo`)操作,搭配模組 `redis.go` 的 key helper。 - `Insert`:ID 為 zero 時自動產生 ObjectID 並寫入 `CreateAt` / `UpdateAt`。 - `Update`:自動更新 `UpdateAt`。 - `FindOne` / `Delete`:無效 ObjectID → `*errs.Error`(`ResInvalidMeasureID`)或模組 `ErrInvalidObjectID`;查無資料 → 模組 `ErrNotFound`(見第 7 節錯誤)。 @@ -162,19 +162,19 @@ type AccountIndexUP interface { ```go type AccountRepositoryParam struct { - Conf *mgo.Conf + Conf *mongo.Conf CacheConf cache.CacheConf DBOpts []mon.Option CacheOpts []cache.Option } type accountRepository struct { - DB mgo.DocumentDBWithCacheUseCase + DB mongo.DocumentDBWithCacheUseCase } func NewAccountRepository(param AccountRepositoryParam) AccountRepository { e := entity.Account{} - documentDB, err := mgo.MustDocumentDBWithCache( + documentDB, err := mongo.MustDocumentDBWithCache( param.Conf, e.CollectionName(), param.CacheConf, param.DBOpts, param.CacheOpts, ) if err != nil { diff --git a/go.mod b/go.mod index a4aa0fd..d2deb90 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,10 @@ require ( github.com/go-playground/locales v0.14.1 github.com/go-playground/universal-translator v0.18.1 github.com/go-playground/validator/v10 v10.30.2 + github.com/shopspring/decimal v1.4.0 github.com/stretchr/testify v1.11.1 github.com/zeromicro/go-zero v1.10.1 + go.mongodb.org/mongo-driver/v2 v2.5.0 google.golang.org/grpc v1.79.3 ) @@ -16,6 +18,7 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fatih/color v1.18.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -37,8 +40,13 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/titanous/json5 v1.0.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect @@ -50,10 +58,13 @@ require ( go.opentelemetry.io/otel/sdk v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect + go.uber.org/mock v0.6.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect diff --git a/go.sum b/go.sum index 82a52ec..ecdba86 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,19 @@ +github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= +github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +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/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= @@ -41,6 +49,8 @@ github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslC github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -72,10 +82,14 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0= github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= @@ -84,8 +98,23 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s= github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zeromicro/go-zero v1.10.1 h1:1nM3ilvYx97GUqyaNH2IQPtfNyK7tp5JvN63c7m6QKU= github.com/zeromicro/go-zero v1.10.1/go.mod h1:z41DXmO6gx/Se7Ow5UIwPxcUmpVj3ebhoNCcZ1gfp5k= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= @@ -110,22 +139,51 @@ go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZY go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= diff --git a/internal/library/mongo/README.md b/internal/library/mongo/README.md new file mode 100644 index 0000000..dcddcac --- /dev/null +++ b/internal/library/mongo/README.md @@ -0,0 +1,331 @@ +# MongoDB(DocumentDB + Cache) + +Gateway 的 **MongoDB 存取層**:在 [go-zero `mon`](https://go-zero.dev/docs/tutorials/mongo/mongo-connections) 之上封裝 collection 操作,並整合 **Redis cache-aside**。業務模組(`internal/model/{module}/repository`)應透過本包存取 DB,而不是直接 new driver client。 + +更上層的分層與 API 流程見: + +- [專案 README](../../README.md) — HTTP、錯誤碼、驗證、目錄總覽 +- [docs/model.md](../../docs/model.md) — entity / repository / usecase 規範 + +--- + +## 流程 + +### 1. 執行期:一筆 API 怎麼走到 Mongo / Redis + +```mermaid +sequenceDiagram + participant C as Client + participant H as handler + participant L as logic + participant UC as usecase + participant R as repository + participant M as library/mongo + participant Redis as Redis + participant Mongo as MongoDB + + C->>H: HTTP + H->>H: httpx.Parse + ValidateAll + H->>L: types req + L->>UC: usecase DTO + UC->>R: 業務方法 + alt 讀取 FindOne + R->>M: FindOne(ctx, cacheKey, doc, filter) + M->>Redis: TakeCtx 查 cache + alt cache hit + Redis-->>M: doc + else cache miss + M->>Mongo: FindOne + Mongo-->>M: doc + M->>Redis: 寫入 cache + end + M-->>R: err / nil + else 寫入 Update/Insert/Delete + R->>M: Update*/Insert*/Delete* + M->>Mongo: 寫入 + M->>Redis: DelCache(key) 失效 + end + R-->>UC: entity / error + UC-->>L: DTO / *errs.Error + L-->>H: types data / err + H-->>C: Status JSON +``` + +### 2. 開發期:新功能從零到上線(建議順序) + +```mermaid +flowchart TD + A[1. 定義 entity + CollectionName] --> B[2. redis.go 定義 cache key] + B --> C[3. repository interface] + C --> D[4. NewRepository: MustDocumentDBWithCache] + D --> E[5. 實作 CRUD 呼叫本包] + E --> F[6. PopulateIndex / IndexUP] + F --> G[7. usecase interface + DTO] + G --> H[8. usecase 實作 + 錯誤轉 errs] + H --> I[9. svc 組裝注入 UseCase] + I --> J[10. generate/api + make gen-api] + J --> K[11. logic: types 映射] + K --> L[12. make check] +``` + +| 步驟 | 做什麼 | 在哪裡 | +|:----:|--------|--------| +| 1 | Document struct、`CollectionName()` | `internal/model/{module}/entity/` | +| 2 | `GetXxxRedisKey()`,與 `FindOne` 的 key 一致 | `internal/model/{module}/redis.go` | +| 3 | `XxxRepository` interface | `internal/model/{module}/repository/` | +| 4 | `mongo.Conf` + `cache.CacheConf` + `mon.Option` | `svc` 或 repository `New*` | +| 5 | `FindOne` / `InsertOne` / … | 同 repository 實作檔 | +| 6 | 建立索引(失敗要處理 error) | 啟動或 migration | +| 7–8 | 業務規則、不直接碰 mongo 包 | `usecase/` | +| 9 | `ServiceContext.MemberUC` 等 | `internal/svc/` | +| 10–11 | HTTP 契約與編排 | `generate/api/`、`internal/logic/` | +| 12 | fmt + lint + test | `make check` | + +### 3. 啟動期:連線與健康檢查 + +```mermaid +flowchart LR + A[NewDocumentDB / MustDocumentDBWithCache] --> B[buildConnectionURI] + B --> C[mon.NewModel] + C --> D[InitMongoOptions + 可選 SetCustomDecimalType] + D --> E[Ping Primary] + E --> F{成功?} + F -->|是| G[注入 repository] + F -->|否| H[啟動失敗 error] +``` + +### 4. Cache 讀寫(本包內部) + +```mermaid +flowchart TD + subgraph 讀取 FindOne + R1[TakeCtx] --> R2{Redis 有 key?} + R2 -->|有| R3[回傳快取] + R2 -->|無| R4[Mongo FindOne] + R4 --> R5[寫入 Redis] + end + subgraph 寫入 Insert/Update/Delete + W1[Mongo 寫入] --> W2[DelCache keys] + W2 --> W3{Redis 刪除失敗?} + W3 -->|是| W4[只 log,仍回成功] + W3 -->|否| W5[完成] + end +``` + +--- + +## 為什麼需要這一層? + +| 問題 | 本包做法 | +|------|----------| +| 各服務自己拼 Mongo URI、各自 cache | 統一 `Conf` + `buildConnectionURI`(密碼 URL 編碼、TLS/query) | +| go-zero 只提供 `mon.Model`,沒有「依 cache key 讀寫」 | `DocumentDBWithCache`:寫入後失效、讀取走 `TakeCtx` + SingleFlight | +| `shopspring/decimal` 與 BSON Decimal128 不一致 | `SetCustomDecimalType()` 註冊 codec | +| 索引建立散落、失敗只 log | `PopulateIndex*` 回傳 `error`,由啟動或 migration 處理 | +| driver v1 / v2 與 go-zero 1.10 不一致 | 全專案對齊 **mongo-driver v2**(與 go-zero 1.10 `mon` 相同) | + +--- + +## 用到的技術與原因 + +| 元件 | 用途 | 為什麼選它 | +|------|------|------------| +| **[go-zero `mon`](https://github.com/zeromicro/go-zero/tree/master/core/stores/mon)** | Collection CRUD、breaker、慢查詢 log | 與 Gateway 同框架,連線共用、可觀測 | +| **[mongo-driver v2](https://www.mongodb.com/docs/drivers/go/current/)** | 官方 Go driver(由 `mon` 使用) | 現行維護版本;go-zero 1.10 已遷移 v2 | +| **[go-zero `cache`](https://github.com/zeromicro/go-zero/tree/master/core/stores/cache)** | Redis cache-aside、`TakeCtx`、SingleFlight | 與 go-zero 生態一致;`ErrNotFound` 不當成 cache 穿透錯誤 | +| **[shopspring/decimal](https://github.com/shopspring/decimal)** | 業務金額精度 | 避免 `float64`;透過自訂 codec 存成 Decimal128 | +| **`gateway/internal/library/errors`** | 對外 API 錯誤(在 repository 轉換) | 本包只提供 `ErrNotFound`;HTTP 8 碼在上一層處理 | + +--- + +## 在整體架構中的位置 + +與上方 **§1 執行期流程** 相同;本包只出現在 **repository** 層。 + +**禁止:** + +- `logic` / `handler` 直接 import 本包或操作 collection +- repository 繞過 `DocumentDBWithCache` 改 DB 卻不處理 cache +- 在業務 struct 上用 `bson.M` 直接吃未驗證的 HTTP JSON(NoSQL operator injection) + +--- + +## 目錄與職責 + +| 檔案 | 職責 | +|------|------| +| `config.go` | `Conf`:連線、pool、壓縮、逾時 | +| `uri.go` | 組裝 / 遮蔽連線 URI | +| `option.go` | `InitMongoOptions`、`SetCustomDecimalType`(`mon.Option`) | +| `doc-db.go` | `NewDocumentDB`、Ping、建立索引 | +| `doc-db-with-cache.go` | 帶 cache 的 CRUD 封裝 | +| `usecase.go` | `DocumentDBUseCase` / `DocumentDBWithCacheUseCase` 介面 | +| `custom_mongo_decimal.go` | Decimal ↔ Decimal128 codec | +| `const.go` | `ErrNotFound`、`singleFlight`、cache stat | +| `uri_test.go` | URI 單元測試 | + +--- + +## 開發步驟詳解 + +以下對應 **§2 開發期流程** 各步驟的程式範例。 + +### 1. 設定與組裝(`internal/svc`) + +在 `ServiceContext` 建立各 collection 的 DB 實例(或 repository 建構時建立): + +```go +import ( + "gateway/internal/library/mongo" + "github.com/zeromicro/go-zero/core/stores/cache" + "github.com/zeromicro/go-zero/core/stores/mon" +) + +mongoConf := &mongo.Conf{ + Schema: "mongodb", + Host: "127.0.0.1:27017", + Database: "gateway", + User: "app", + Password: "secret", + AuthSource: "admin", +} + +dbOpts := []mon.Option{ + mongo.InitMongoOptions(*mongoConf), + mongo.SetCustomDecimalType(), // 有 decimal 欄位才需要 +} + +accountDB, err := mongo.MustDocumentDBWithCache( + mongoConf, + "account", // collection 名稱,與 entity.CollectionName() 一致 + cache.CacheConf{ /* Host, Pass, ... */ }, + dbOpts, + nil, +) +``` + +**為什麼在 svc / repository 建構?** 連線與 cache 屬基礎設施,生命週期應與 process 相同;業務 usecase 只依賴 interface。 + +### 2. Entity 與 Redis key(`internal/model/{module}/`) + +1. 在 `entity/` 定義 document + `CollectionName()`。 +2. 在 `redis.go` 定義 cache key,例如 `GetAccountRedisKey(id)`。 +3. key 規則要與 **讀取** `FindOne(ctx, key, ...)` 的 `key` 參數一致。 + +### 3. Repository 實作(`repository/`) + +1. struct 持有 `mongo.DocumentDBWithCacheUseCase`。 +2. `NewXxxRepository` 內 `MustDocumentDBWithCache`(啟動失敗 `panic`)。 +3. CRUD 範例: + +```go +func (r *accountRepository) FindOne(ctx context.Context, id string) (*entity.Account, error) { + var doc entity.Account + filter := bson.M{"_id": oid} + err := r.DB.FindOne(ctx, member.GetAccountRedisKey(id), &doc, filter) + if err != nil { + if errors.Is(err, mongo.ErrNotFound) { + return nil, member.ErrNotFound + } + return nil, err + } + return &doc, nil +} +``` + +4. 錯誤在 usecase 轉成 `gateway/internal/library/errors`(見 [errors README](../errors/README.md))。 +5. 啟動或 migration 呼叫 `PopulateIndex` / `IndexUP`(回傳 error 要處理)。 + +詳見 [docs/model.md](../../docs/model.md) 第 4、11 節。 + +### 4. UseCase 與 Logic + +- UseCase 只依賴 `repository` interface,不 import 本包。 +- Logic 只呼叫 usecase,做 `types` ↔ DTO 映射。 + +### 5. 新增對外 API(可選) + +1. `generate/api/*.api` → `make gen-api` +2. logic 實作 → `make check` + +--- + +## 設定說明(`Conf`) + +| 欄位 | 說明 | +|------|------| +| `Schema` | `mongodb` 或 `mongodb+srv`(Atlas) | +| `Host` | `host:port` 或 srv 的 host | +| `User` / `Password` | 會經 URL 編碼,勿手拼 URI | +| `Database` | 傳入 `mon.NewModel` 的 db 名稱 | +| `AuthSource` | 查詢參數 `authSource` | +| `ReplicaName` | 查詢參數 `replicaSet` | +| `TLS` | 查詢參數 `tls=true` | +| `MaxPoolSize` / `MinPoolSize` / `MaxConnIdleTime` | client pool | +| `Compressors` | 預設 `zstd`、`snappy` | +| `ConnectTimeoutMs` | 啟動 Ping 逾時(預設 10s) | + +尚未接到 `etc/gateway.yaml` 時,可在 `ServiceContext` 從環境變數或本地 yaml 填入 `Conf`。 + +--- + +## Cache 語意(必讀) + +| 方法 | 行為 | +|------|------| +| `FindOne` | `cache.TakeCtx`:miss 時查 Mongo,並寫入 cache | +| `InsertOne` / `Update*` / `DeleteOne` | DB 成功後 **刪除** 傳入的 cache key(s) | +| `FindOneAndDelete` / `FindOneAndReplace` | DB 成功後刪除 key | + +**寫入後刪 cache 失敗**:只記 error log,**不回傳錯誤**(避免 DB 已寫入卻對外報錯);可能短暫讀到舊快取,需接受或改為強一致策略。 + +**未封裝的操作**:`Find`、列表、aggregate 沒有 cache;若直接 `GetClient()` 改 DB,cache **不會**自動失效。 + +--- + +## 介面一覽 + +- `DocumentDBUseCase`:連線、索引、`GetClient() *mon.Model`(進階或繞過 cache 時慎用) +- `DocumentDBWithCacheUseCase`:業務 repository 應使用此介面 +- `CacheUseCase`:`DelCache` / `GetCache` / `SetCache`(皆帶 `context`) + +`ErrNotFound` 與 `mon.ErrNotFound` / `mongo.ErrNoDocuments` 相同,供 repository 判斷「無資料」。 + +--- + +## 測試 + +| 類型 | 內容 | 何時跑 | +|------|------|--------| +| 單元 | `uri_test.go`(URI 編碼、遮蔽) | `make test` / `make check` | +| 建議補 | `custom_mongo_decimal` 往返測試 | 有金額欄位時 | +| 整合 | testcontainers + miniredis,測 repository CRUD + cache | 第一個 repository 完成時;可標 `//go:build integration` | + +本包 **不強制** testcontainers;**業務 repository 的整合測試** 才是上線前主要門禁。 + +--- + +## 維運注意 + +- 啟動時 Ping **Primary**;讀取若要走 secondary 請在 URI / client 設定 read preference。 +- 多 collection = 多次 `MustDocumentDBWithCache`(每個 collection 一個實例)。 +- 程序關閉:目前未封裝 `Disconnect`;依 go-zero client manager 生命週期,必要時可在 shutdown hook 補強。 +- Health API 建議另做 Mongo Ping(給 K8s probe),與啟動 Ping 分開。 + +--- + +## 常用指令 + +```bash +# 在專案根目錄 +make test ./internal/library/mongo/... +make check # 改 Go 後建議全跑 +``` + +--- + +## 版本與遷移備註 + +- 2023 舊版曾使用獨立 module `library-go/mongo` + driver **v1**;已併入 `gateway` 並升級 **v2**。 +- `SetCustomDecimalType` 使用 go-zero 內建 `mon.WithTypeCodec`,勿再維護重複的 registry 註冊邏輯。 +- Repository 介面若仍 import `go.mongodb.org/mongo-driver/mongo`(v1),請改為 **v2** 路徑:`go.mongodb.org/mongo-driver/v2/mongo`。 diff --git a/internal/library/mongo/config.go b/internal/library/mongo/config.go new file mode 100644 index 0000000..323b4e5 --- /dev/null +++ b/internal/library/mongo/config.go @@ -0,0 +1,20 @@ +package mongo + +import "time" + +// Conf is MongoDB client configuration for DocumentDB helpers. +type Conf struct { + Schema string + User string + Password string + Host string + Database string + AuthSource string + ReplicaName string + TLS bool + MaxPoolSize uint64 + MinPoolSize uint64 + MaxConnIdleTime time.Duration + Compressors []string + ConnectTimeoutMs int64 +} diff --git a/internal/library/mongo/const.go b/internal/library/mongo/const.go new file mode 100644 index 0000000..9cda21b --- /dev/null +++ b/internal/library/mongo/const.go @@ -0,0 +1,15 @@ +package mongo + +import ( + "github.com/zeromicro/go-zero/core/stores/cache" + "github.com/zeromicro/go-zero/core/stores/mon" + "github.com/zeromicro/go-zero/core/syncx" +) + +var ( + // ErrNotFound matches go-zero mon / mongo driver "no documents". + ErrNotFound = mon.ErrNotFound + + singleFlight = syncx.NewSingleFlight() + stats = cache.NewStat("mongoc") +) diff --git a/internal/library/mongo/custom_mongo_decimal.go b/internal/library/mongo/custom_mongo_decimal.go new file mode 100755 index 0000000..4298eb8 --- /dev/null +++ b/internal/library/mongo/custom_mongo_decimal.go @@ -0,0 +1,45 @@ +package mongo + +import ( + "fmt" + "reflect" + + "github.com/shopspring/decimal" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type MgoDecimal struct{} + +var ( + _ bson.ValueEncoder = &MgoDecimal{} + _ bson.ValueDecoder = &MgoDecimal{} +) + +func (dc *MgoDecimal) EncodeValue(_ bson.EncodeContext, vw bson.ValueWriter, value reflect.Value) error { + dec, ok := value.Interface().(decimal.Decimal) + if !ok { + return fmt.Errorf("value %v is not decimal.Decimal", value.Interface()) + } + + primDec, err := bson.ParseDecimal128(dec.String()) + if err != nil { + return fmt.Errorf("decimal %v to Decimal128: %w", dec, err) + } + + return vw.WriteDecimal128(primDec) +} + +func (dc *MgoDecimal) DecodeValue(_ bson.DecodeContext, vr bson.ValueReader, value reflect.Value) error { + primDec, err := vr.ReadDecimal128() + if err != nil { + return fmt.Errorf("read Decimal128: %w", err) + } + + dec, err := decimal.NewFromString(primDec.String()) + if err != nil { + return fmt.Errorf("Decimal128 %v to decimal: %w", primDec, err) + } + + value.Set(reflect.ValueOf(dec)) + return nil +} diff --git a/internal/library/mongo/doc-db-with-cache.go b/internal/library/mongo/doc-db-with-cache.go new file mode 100755 index 0000000..24bc34d --- /dev/null +++ b/internal/library/mongo/doc-db-with-cache.go @@ -0,0 +1,126 @@ +package mongo + +import ( + "context" + "fmt" + + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/core/stores/cache" + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type DocumentDBWithCache struct { + DocumentDBUseCase + Cache cache.Cache +} + +func MustDocumentDBWithCache( + conf *Conf, + collection string, + cacheConf cache.CacheConf, + dbOpts []mon.Option, + cacheOpts []cache.Option, +) (DocumentDBWithCacheUseCase, error) { + documentDB, err := NewDocumentDB(conf, collection, dbOpts...) + if err != nil { + return nil, fmt.Errorf("mongo: document db: %w", err) + } + return &DocumentDBWithCache{ + DocumentDBUseCase: documentDB, + Cache: MustModelCache(cacheConf, cacheOpts...), + }, nil +} + +func (dc *DocumentDBWithCache) DelCache(ctx context.Context, keys ...string) error { + return dc.Cache.DelCtx(ctx, keys...) +} + +func (dc *DocumentDBWithCache) GetCache(ctx context.Context, key string, v any) error { + return dc.Cache.GetCtx(ctx, key, v) +} + +func (dc *DocumentDBWithCache) SetCache(ctx context.Context, key string, v any) error { + return dc.Cache.SetCtx(ctx, key, v) +} + +func (dc *DocumentDBWithCache) DeleteOne(ctx context.Context, key string, filter any, opts ...options.Lister[options.DeleteOneOptions]) (int64, error) { + val, err := dc.GetClient().DeleteOne(ctx, filter, opts...) + if err != nil { + return 0, err + } + dc.delCacheBestEffort(ctx, key) + return val, nil +} + +func (dc *DocumentDBWithCache) FindOne(ctx context.Context, key string, v, filter any, opts ...options.Lister[options.FindOneOptions]) error { + return dc.Cache.TakeCtx(ctx, v, key, func(v any) error { + return dc.GetClient().FindOne(ctx, v, filter, opts...) + }) +} + +func (dc *DocumentDBWithCache) FindOneAndDelete(ctx context.Context, key string, v, filter any, opts ...options.Lister[options.FindOneAndDeleteOptions]) error { + if err := dc.GetClient().FindOneAndDelete(ctx, v, filter, opts...); err != nil { + return err + } + dc.delCacheBestEffort(ctx, key) + return nil +} + +func (dc *DocumentDBWithCache) FindOneAndReplace(ctx context.Context, key string, v, filter, replacement any, opts ...options.Lister[options.FindOneAndReplaceOptions]) error { + if err := dc.GetClient().FindOneAndReplace(ctx, v, filter, replacement, opts...); err != nil { + return err + } + dc.delCacheBestEffort(ctx, key) + return nil +} + +func (dc *DocumentDBWithCache) InsertOne(ctx context.Context, key string, document any, opts ...options.Lister[options.InsertOneOptions]) (*mongo.InsertOneResult, error) { + res, err := dc.GetClient().InsertOne(ctx, document, opts...) + if err != nil { + return nil, err + } + dc.delCacheBestEffort(ctx, key) + return res, nil +} + +func (dc *DocumentDBWithCache) UpdateByID(ctx context.Context, key string, id, update any, opts ...options.Lister[options.UpdateOneOptions]) (*mongo.UpdateResult, error) { + res, err := dc.GetClient().UpdateByID(ctx, id, update, opts...) + if err != nil { + return nil, err + } + dc.delCacheBestEffort(ctx, key) + return res, nil +} + +func (dc *DocumentDBWithCache) UpdateMany(ctx context.Context, keys []string, filter, update any, opts ...options.Lister[options.UpdateManyOptions]) (*mongo.UpdateResult, error) { + res, err := dc.GetClient().UpdateMany(ctx, filter, update, opts...) + if err != nil { + return nil, err + } + dc.delCacheBestEffort(ctx, keys...) + return res, nil +} + +func (dc *DocumentDBWithCache) UpdateOne(ctx context.Context, key string, filter, update any, opts ...options.Lister[options.UpdateOneOptions]) (*mongo.UpdateResult, error) { + res, err := dc.GetClient().UpdateOne(ctx, filter, update, opts...) + if err != nil { + return nil, err + } + dc.delCacheBestEffort(ctx, key) + return res, nil +} + +func (dc *DocumentDBWithCache) delCacheBestEffort(ctx context.Context, keys ...string) { + if len(keys) == 0 { + return + } + if err := dc.DelCache(ctx, keys...); err != nil { + logx.WithContext(ctx).Errorf("[DocumentDBWithCache] del cache keys=%v: %v", keys, err) + } +} + +func MustModelCache(conf cache.CacheConf, opts ...cache.Option) cache.Cache { + return cache.New(conf, singleFlight, stats, ErrNotFound, opts...) +} diff --git a/internal/library/mongo/doc-db.go b/internal/library/mongo/doc-db.go new file mode 100755 index 0000000..938639b --- /dev/null +++ b/internal/library/mongo/doc-db.go @@ -0,0 +1,106 @@ +package mongo + +import ( + "context" + "fmt" + "time" + + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + "go.mongodb.org/mongo-driver/v2/mongo/readpref" +) + +type DocumentDB struct { + Mon *mon.Model +} + +func NewDocumentDB(config *Conf, collection string, opts ...mon.Option) (DocumentDBUseCase, error) { + if config == nil { + return nil, fmt.Errorf("mongo: config is nil") + } + if collection == "" { + return nil, fmt.Errorf("mongo: collection is required") + } + + connectionURI, err := buildConnectionURI(*config) + if err != nil { + return nil, err + } + + opts = append(opts, InitMongoOptions(*config)) + logx.Infof("[DocumentDB] connecting to %s db=%s coll=%s", redactConnectionURI(connectionURI), config.Database, collection) + + client, err := mon.NewModel(connectionURI, config.Database, collection, opts...) + if err != nil { + return nil, fmt.Errorf("mongo: new model: %w", err) + } + + timeout := time.Duration(config.ConnectTimeoutMs) * time.Millisecond + if timeout <= 0 { + timeout = 10 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if err := client.Collection.Database().Client().Ping(ctx, readpref.Primary()); err != nil { + return nil, fmt.Errorf("mongo: ping primary: %w", err) + } + logx.Infof("[DocumentDB] connected") + + return &DocumentDB{Mon: client}, nil +} + +func (document *DocumentDB) PopulateIndex(ctx context.Context, key string, sort int32, unique bool) error { + return document.createIndex(ctx, []string{key}, []int32{sort}, unique, nil) +} + +func (document *DocumentDB) PopulateTTLIndex(ctx context.Context, key string, sort int32, unique bool, ttl int32) error { + return document.createIndex(ctx, []string{key}, []int32{sort}, unique, options.Index().SetExpireAfterSeconds(ttl)) +} + +func (document *DocumentDB) PopulateMultiIndex(ctx context.Context, keys []string, sorts []int32, unique bool) error { + if len(keys) != len(sorts) { + return fmt.Errorf("mongo: keys and sorts length mismatch") + } + return document.createIndex(ctx, keys, sorts, unique, nil) +} + +func (document *DocumentDB) createIndex( + ctx context.Context, + keys []string, + sorts []int32, + unique bool, + indexOpt *options.IndexOptionsBuilder, +) error { + c := document.Mon.Collection + index := yieldIndexModel(keys, sorts, unique, indexOpt) + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + _, err := c.Indexes().CreateOne(ctx, index) + if err != nil { + return fmt.Errorf("mongo: create index: %w", err) + } + return nil +} + +func (document *DocumentDB) GetClient() *mon.Model { + return document.Mon +} + +func yieldIndexModel(keys []string, sorts []int32, unique bool, indexOpt *options.IndexOptionsBuilder) mongo.IndexModel { + setKeysDoc := bson.D{} + for i, key := range keys { + setKeysDoc = append(setKeysDoc, bson.E{Key: key, Value: sorts[i]}) + } + if indexOpt == nil { + indexOpt = options.Index() + } + indexOpt.SetUnique(unique) + return mongo.IndexModel{ + Keys: setKeysDoc, + Options: indexOpt, + } +} diff --git a/internal/library/mongo/option.go b/internal/library/mongo/option.go new file mode 100755 index 0000000..4b2bf58 --- /dev/null +++ b/internal/library/mongo/option.go @@ -0,0 +1,34 @@ +package mongo + +import ( + "reflect" + + "github.com/shopspring/decimal" + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// SetCustomDecimalType registers decimal.Decimal <-> BSON Decimal128 conversion. +func SetCustomDecimalType() mon.Option { + return mon.WithTypeCodec(mon.TypeCodec{ + ValueType: reflect.TypeOf(decimal.Decimal{}), + Encoder: &MgoDecimal{}, + Decoder: &MgoDecimal{}, + }) +} + +// InitMongoOptions applies pool / compressor settings from Conf. +func InitMongoOptions(cfg Conf) mon.Option { + return func(opts *options.ClientOptions) { + if cfg.MaxPoolSize > 0 { + opts.SetMaxPoolSize(cfg.MaxPoolSize) + } + if cfg.MinPoolSize > 0 { + opts.SetMinPoolSize(cfg.MinPoolSize) + } + if cfg.MaxConnIdleTime > 0 { + opts.SetMaxConnIdleTime(cfg.MaxConnIdleTime) + } + opts.SetCompressors(defaultCompressors(cfg)) + } +} diff --git a/internal/library/mongo/uri.go b/internal/library/mongo/uri.go new file mode 100644 index 0000000..e5470bc --- /dev/null +++ b/internal/library/mongo/uri.go @@ -0,0 +1,59 @@ +package mongo + +import ( + "fmt" + "net/url" +) + +func buildConnectionURI(c Conf) (string, error) { + scheme := c.Schema + if scheme == "" { + scheme = "mongodb" + } + if c.Host == "" { + return "", fmt.Errorf("mongo: host is required") + } + + u := &url.URL{ + Scheme: scheme, + Host: c.Host, + } + if c.User != "" { + u.User = url.UserPassword(c.User, c.Password) + } + + q := url.Values{} + if c.AuthSource != "" { + q.Set("authSource", c.AuthSource) + } + if c.ReplicaName != "" { + q.Set("replicaSet", c.ReplicaName) + } + if c.TLS { + q.Set("tls", "true") + } + if len(q) > 0 { + u.RawQuery = q.Encode() + } + + return u.String(), nil +} + +func redactConnectionURI(uri string) string { + u, err := url.Parse(uri) + if err != nil || u.User == nil { + return uri + } + if _, hasPassword := u.User.Password(); !hasPassword { + return uri + } + u.User = url.UserPassword(u.User.Username(), "*****") + return u.String() +} + +func defaultCompressors(c Conf) []string { + if len(c.Compressors) > 0 { + return c.Compressors + } + return []string{"zstd", "snappy"} +} diff --git a/internal/library/mongo/uri_test.go b/internal/library/mongo/uri_test.go new file mode 100644 index 0000000..5b43c70 --- /dev/null +++ b/internal/library/mongo/uri_test.go @@ -0,0 +1,31 @@ +package mongo + +import "testing" + +func TestBuildConnectionURI_escapesPassword(t *testing.T) { + t.Parallel() + + uri, err := buildConnectionURI(Conf{ + Schema: "mongodb", + Host: "127.0.0.1:27017", + User: "user", + Password: "p@ss:word", + AuthSource: "admin", + }) + if err != nil { + t.Fatal(err) + } + if uri != "mongodb://user:p%40ss%3Aword@127.0.0.1:27017?authSource=admin" { + t.Fatalf("uri = %q", uri) + } +} + +func TestRedactConnectionURI(t *testing.T) { + t.Parallel() + + raw := "mongodb://user:secret@127.0.0.1:27017" + got := redactConnectionURI(raw) + if got == raw { + t.Fatalf("expected redacted uri, got %q", got) + } +} diff --git a/internal/library/mongo/usecase.go b/internal/library/mongo/usecase.go new file mode 100644 index 0000000..bb29042 --- /dev/null +++ b/internal/library/mongo/usecase.go @@ -0,0 +1,35 @@ +package mongo + +import ( + "context" + + "github.com/zeromicro/go-zero/core/stores/mon" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type DocumentDBUseCase interface { + PopulateIndex(ctx context.Context, key string, sort int32, unique bool) error + PopulateTTLIndex(ctx context.Context, key string, sort int32, unique bool, ttl int32) error + PopulateMultiIndex(ctx context.Context, keys []string, sorts []int32, unique bool) error + GetClient() *mon.Model +} + +type DocumentDBWithCacheUseCase interface { + DocumentDBUseCase + CacheUseCase + DeleteOne(ctx context.Context, key string, filter any, opts ...options.Lister[options.DeleteOneOptions]) (int64, error) + FindOne(ctx context.Context, key string, v, filter any, opts ...options.Lister[options.FindOneOptions]) error + FindOneAndDelete(ctx context.Context, key string, v, filter any, opts ...options.Lister[options.FindOneAndDeleteOptions]) error + FindOneAndReplace(ctx context.Context, key string, v, filter, replacement any, opts ...options.Lister[options.FindOneAndReplaceOptions]) error + InsertOne(ctx context.Context, key string, document any, opts ...options.Lister[options.InsertOneOptions]) (*mongo.InsertOneResult, error) + UpdateByID(ctx context.Context, key string, id, update any, opts ...options.Lister[options.UpdateOneOptions]) (*mongo.UpdateResult, error) + UpdateMany(ctx context.Context, keys []string, filter, update any, opts ...options.Lister[options.UpdateManyOptions]) (*mongo.UpdateResult, error) + UpdateOne(ctx context.Context, key string, filter, update any, opts ...options.Lister[options.UpdateOneOptions]) (*mongo.UpdateResult, error) +} + +type CacheUseCase interface { + DelCache(ctx context.Context, keys ...string) error + GetCache(ctx context.Context, key string, v any) error + SetCache(ctx context.Context, key string, v any) error +}