diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..461da38 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: Unit Testing + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + go-version: ["1.21", "1.22", "1.23"] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Run tests + run: | + go test -v -cover ./... \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..60e1919 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,34 @@ +name: Publish + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Module tidy + run: go mod tidy + + - name: Publish to proxy + env: + GOPROXY: proxy.golang.org + run: | + # 确保所有依赖项都正确安装 + go list -m all + + # 使用 go list 命令请求版本的代理缓存,以便 proxy.golang.org 缓存新版本 + go list -m github.com/growingio/growingio-sdk-go@${GITHUB_REF#refs/tags/} + + echo "Published to proxy.golang.org" + + - name: Confirm the module is available on proxy.golang.org + run: | + curl -sL https://proxy.golang.org/github.com/growingio/growingio-sdk-go/@v/${GITHUB_REF#refs/tags/}.info diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..97f26c9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ + +## [1.0.1](https://github.com/growingio/growingio-sdk-go/tree/v1.0.1) (2024-11-11) + +### Changed + +* 更好的调用性能 +* 提升代码质量 +* 添加单元测试 + +## [1.0.0](https://github.com/growingio/growingio-sdk-go/tree/v1.0.0) (2024-10-23) + +### Features + +* 支持埋点事件 +* 支持用户属性事件 +* 支持维度表上报 + diff --git a/analytics.go b/analytics.go index de3117b..e261377 100644 --- a/analytics.go +++ b/analytics.go @@ -31,6 +31,8 @@ var lock = make(chan struct{}, 1) func InitAnalytics(config *Config) error { lock <- struct{}{} defer func() { <-lock }() + // create the logger before any calls are made + logger.NewLogger() if core.InitializedSuccessfully { err := errors.New("initialization failed, already initialized") @@ -75,6 +77,7 @@ func InitAnalytics(config *Config) error { core.RoutineCount = config.BatchConfig.RoutineCount core.MaxCacheSize = config.BatchConfig.MaxCacheSize core.InitBatch() + core.RunBatch() } core.InitializedSuccessfully = true @@ -98,85 +101,85 @@ func InitAnalyticsByConfigFile(file string) error { return InitAnalytics(config) } -func TrackCustomEvent(builder *CustomEventBuilder) error { +func TrackCustomEvent(b *CustomEventBuilder) error { if !core.InitializedSuccessfully { err := errors.New("TrackCustomEvent failed, GrowingAnalytics has not been initialized") logger.Error(err, "Please init GrowingAnalytics first") return err } - if len(builder.EventName) == 0 { + if len(b.EventName) == 0 { err := errors.New("TrackCustomEvent failed, EventName is empty") logger.Error(err, "Please enter eventName for customEvent") return err } - if len(builder.AnonymousId)+len(builder.LoginUserId) == 0 { + if len(b.AnonymousId)+len(b.LoginUserId) == 0 { err := errors.New("TrackCustomEvent failed, AnonymousId and LoginUserId are empty") logger.Error(err, "Both AnonymousId and LoginUserId are empty. Please enter at least one of them") return err } - b := core.EventBuilder{ - EventName: builder.EventName, - EventTime: builder.EventTime, - AnonymousId: builder.AnonymousId, - LoginUserId: builder.LoginUserId, - LoginUserKey: builder.LoginUserKey, - Attributes: builder.Attributes, + builder := &core.EventBuilder{ + EventName: b.EventName, + EventTime: b.EventTime, + AnonymousId: b.AnonymousId, + LoginUserId: b.LoginUserId, + LoginUserKey: b.LoginUserKey, + Attributes: b.Attributes, } - b.BuildCustomEvent() + core.BuildCustomEvent(builder) return nil } -func TrackUser(builder *UserEventBuilder) error { +func TrackUser(b *UserEventBuilder) error { if !core.InitializedSuccessfully { err := errors.New("TrackUser failed, GrowingAnalytics has not been initialized") logger.Error(err, "Please init GrowingAnalytics first") return err } - if len(builder.LoginUserId) == 0 { + if len(b.LoginUserId) == 0 { err := errors.New("TrackUser failed, LoginUserId is empty") logger.Error(err, "Please enter loginUserId for user") return err } - b := core.EventBuilder{ - EventTime: builder.EventTime, - AnonymousId: builder.AnonymousId, - LoginUserId: builder.LoginUserId, - LoginUserKey: builder.LoginUserKey, - Attributes: builder.Attributes, + builder := &core.EventBuilder{ + EventTime: b.EventTime, + AnonymousId: b.AnonymousId, + LoginUserId: b.LoginUserId, + LoginUserKey: b.LoginUserKey, + Attributes: b.Attributes, } - b.BuildUserLoginEvent() + core.BuildUserLoginEvent(builder) return nil } -func SubmitItem(builder *ItemBuilder) error { +func SubmitItem(b *ItemBuilder) error { if !core.InitializedSuccessfully { err := errors.New("SubmitItem failed, GrowingAnalytics has not been initialized") logger.Error(err, "Please init GrowingAnalytics first") return err } - if len(builder.ItemId) == 0 { + if len(b.ItemId) == 0 { err := errors.New("SubmitItem failed, ItemId is empty") logger.Error(err, "Please enter itemId for item") return err } - if len(builder.ItemKey) == 0 { + if len(b.ItemKey) == 0 { err := errors.New("SubmitItem failed, ItemKey is empty") logger.Error(err, "Please enter itemKey for item") return err } - b := core.EventBuilder{ - ItemId: builder.ItemId, - ItemKey: builder.ItemKey, - Attributes: builder.Attributes, + builder := &core.EventBuilder{ + ItemId: b.ItemId, + ItemKey: b.ItemKey, + Attributes: b.Attributes, } - b.BuildItemEvent() + core.BuildItemEvent(builder) return nil } diff --git a/go.mod b/go.mod index 823108e..3fae4ea 100644 --- a/go.mod +++ b/go.mod @@ -6,10 +6,14 @@ require ( github.com/go-logr/logr v1.4.2 github.com/go-logr/stdr v1.2.2 github.com/golang/snappy v0.0.4 + github.com/stretchr/testify v1.9.0 google.golang.org/protobuf v1.35.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/go-cmp v0.6.0 // indirect - gopkg.in/yaml.v3 v3.0.1 + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect ) diff --git a/go.sum b/go.sum index 6daefb5..dc733c1 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +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/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -7,6 +9,12 @@ github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/hack/boilerplate/boilerplate.generatego.txt b/hack/boilerplate/boilerplate.generatego.txt new file mode 100644 index 0000000..6c4ece8 --- /dev/null +++ b/hack/boilerplate/boilerplate.generatego.txt @@ -0,0 +1,15 @@ +// +// @license +// Copyright (C) 2024 Beijing Yishu Technology Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. \ No newline at end of file diff --git a/internal/core/.mockery.yaml b/internal/core/.mockery.yaml new file mode 100644 index 0000000..a97330a --- /dev/null +++ b/internal/core/.mockery.yaml @@ -0,0 +1,10 @@ +dir: . +filename: "mock_{{.InterfaceName | snakecase}}_test.go" +boilerplate-file: ../../hack/boilerplate/boilerplate.generatego.txt +inpackage: true +with-expecter: true +packages: + github.com/growingio/growingio-sdk-go/internal/core: + interfaces: + Batch: + Request: diff --git a/internal/core/batch.go b/internal/core/batch.go index bef9f42..352a1a5 100644 --- a/internal/core/batch.go +++ b/internal/core/batch.go @@ -29,20 +29,25 @@ const defaultRoutineCount = 16 const defaultMaxCacheSize = 10240 var ( - BatchEnable bool - MaxSize int - FlushAfter int - + BatchEnable bool + MaxSize int + FlushAfter int RoutineCount int MaxCacheSize int - batch *Batch + bInst Batch + routine chan struct{} ) -type Batch struct { - routine chan struct{} - events chan *protobuf.EventV3Dto - items chan *protobuf.ItemDto +type Batch interface { + pushEvent(event *protobuf.EventV3Dto) + pushItem(item *protobuf.ItemDto) + pop() +} + +type batch struct { + events chan *protobuf.EventV3Dto + items chan *protobuf.ItemDto } func InitBatch() { @@ -59,48 +64,54 @@ func InitBatch() { MaxCacheSize = defaultMaxCacheSize } - batch = &Batch{ - events: make(chan *protobuf.EventV3Dto, MaxCacheSize), - items: make(chan *protobuf.ItemDto, MaxCacheSize), - routine: make(chan struct{}, RoutineCount), - } + routine = make(chan struct{}, RoutineCount) + bInst = NewBatch() +} - go send() +func RunBatch() { + go run(bInst) +} + +func NewBatch() Batch { + return &batch{ + events: make(chan *protobuf.EventV3Dto, MaxCacheSize), + items: make(chan *protobuf.ItemDto, MaxCacheSize), + } } -func send() { +func run(b Batch) { for { - batch.routine <- struct{}{} + routine <- struct{}{} go func() { - defer func() { <-batch.routine }() - batch.pop() + defer func() { <-routine }() + b.pop() }() } } -func (batch *Batch) pushEvent(event *protobuf.EventV3Dto) { - batch.events <- event +func (b *batch) pushEvent(event *protobuf.EventV3Dto) { + b.events <- event } -func (batch *Batch) pushItem(item *protobuf.ItemDto) { - batch.items <- item +func (b *batch) pushItem(item *protobuf.ItemDto) { + b.items <- item } -func (batch *Batch) pop() { +func (b *batch) pop() { var events []*protobuf.EventV3Dto var items []*protobuf.ItemDto -L: +Query: for { select { - case e := <-batch.events: + case e := <-b.events: events = append(events, e) if len(events) >= MaxSize { logger.Debug("sending events due to exceeding limit", "count", len(events), "limit", MaxSize) sendEvents(events) events = make([]*protobuf.EventV3Dto, 0) } - case i := <-batch.items: + case i := <-b.items: items = append(items, i) if len(items) >= MaxSize { logger.Debug("sending items due to exceeding limit", "count", len(items), "limit", MaxSize) @@ -108,7 +119,7 @@ L: items = make([]*protobuf.ItemDto, 0) } case <-time.After(time.Duration(FlushAfter) * time.Second): - break L + break Query } } diff --git a/internal/core/batch_test.go b/internal/core/batch_test.go index 40bdfa7..5b7276b 100644 --- a/internal/core/batch_test.go +++ b/internal/core/batch_test.go @@ -17,65 +17,114 @@ package core import ( - "sync" "testing" "time" - "github.com/go-logr/stdr" - "github.com/growingio/growingio-sdk-go/internal/logger" "github.com/growingio/growingio-sdk-go/internal/protobuf" + "github.com/stretchr/testify/assert" ) -func createEvent(seqId int32) *protobuf.EventV3Dto { - event := &protobuf.EventV3Dto{ - ProjectKey: "123456", - DataSourceId: "654321", - SdkVersion: "1.0.0", - Platform: "go", - } +func TestInitBatch(t *testing.T) { + MaxSize = 0 + FlushAfter = 0 + RoutineCount = 0 + MaxCacheSize = 0 + routine = nil + bInst = nil - event.EventType = protobuf.EventType_CUSTOM - event.EventName = "name_" + string(seqId) + InitBatch() - timestamp := time.Now().UnixMilli() - event.Timestamp = timestamp - event.SendTime = timestamp + assert.Equal(t, defaultMaxSize, MaxSize, "MaxSize should be initialized to defaultMaxSize") + assert.Equal(t, defaultFlushAfter, FlushAfter, "FlushAfter should be initialized to defaultFlushAfter") + assert.Equal(t, defaultRoutineCount, RoutineCount, "RoutineCount should be initialized to defaultRoutineCount") + assert.Equal(t, defaultMaxCacheSize, MaxCacheSize, "MaxCacheSize should be initialized to defaultMaxCacheSize") - event.UserId = "user1" - return event + assert.NotNil(t, routine, "routine channel should be initialized") + assert.Equal(t, RoutineCount, cap(routine), "routine channel capacity should be equal to RoutineCount") + assert.NotNil(t, bInst, "bInst should be initialized") } -func TestSendEvent(t *testing.T) { - // 手动修改以下几个参数,进行测试 - // 是否batch发送 - BatchEnable = false - // 单包大小 - MaxSize = 500 - // 设置goroutine数 - RoutineCount = 3 - // 设置超时间隔 - FlushAfter = 3 - // 模拟事件数量 - eventCount := int32(MaxSize*2 + (MaxSize - 1)) - // 内存中缓存的最大事件数量,一般不修改 - MaxCacheSize = 10240 - - // 打开日志 - stdr.SetVerbosity(8) - logger.Debug("begin TestSendEvent") - - // 初始化batch +func TestInitBatch_NoDefault(t *testing.T) { + MaxSize = 1 + FlushAfter = 1 + RoutineCount = 1 + MaxCacheSize = 1 + routine = nil + bInst = nil + InitBatch() - // 模拟埋点事件 - for i := int32(0); i < eventCount; i++ { - go batch.pushEvent(createEvent(i)) + + assert.NotEqual(t, defaultMaxSize, MaxSize, "MaxSize should be initialized to defaultMaxSize") + assert.NotEqual(t, defaultFlushAfter, FlushAfter, "FlushAfter should be initialized to defaultFlushAfter") + assert.NotEqual(t, defaultRoutineCount, RoutineCount, "RoutineCount should be initialized to defaultRoutineCount") + assert.NotEqual(t, defaultMaxCacheSize, MaxCacheSize, "MaxCacheSize should be initialized to defaultMaxCacheSize") + + assert.NotNil(t, routine, "routine channel should be initialized") + assert.Equal(t, RoutineCount, cap(routine), "routine channel capacity should be equal to RoutineCount") + assert.NotNil(t, bInst, "bInst should be initialized") +} + +func TestNewBatch(t *testing.T) { + b := NewBatch() + + assert.NotNil(t, b) + assert.NotNil(t, b.(*batch).events) + assert.NotNil(t, b.(*batch).items) + + assert.Equal(t, cap(b.(*batch).events), MaxCacheSize) + assert.Equal(t, cap(b.(*batch).items), MaxCacheSize) +} + +func TestRun(t *testing.T) { + routineCount := 4 + routine = make(chan struct{}, routineCount) + mockBatch := NewMockBatch(t) + + // Create a channel to record the current number of pop() callers + // The buffer size is set to routineCount*2 to avoid blocking + curCallChan := make(chan struct{}, routineCount*2) + + mockBatch.EXPECT().pop().Run(func() { + // When pop() is called, send a struct{} to curCallChan + curCallChan <- struct{}{} + // Get the current number of pop() callers + curCount := len(curCallChan) + // Ensure that the current caller count does not exceed routineCount + assert.LessOrEqual(t, curCount, routineCount) + // Read from curCallChan to reduce the caller count after execution + <-curCallChan + }).After(10 * time.Microsecond) // Delay pop() execution by 10 microseconds to simulate processing time + + // Start the run() method, which will invoke pop() in a loop + go run(mockBatch) + + // Wait for 5 seconds to allow run() enough time to make multiple pop() calls + time.Sleep(5000 * time.Millisecond) + mockBatch.AssertExpectations(t) +} + +func TestPushEvent(t *testing.T) { + b := NewBatch() + event := &protobuf.EventV3Dto{} + b.pushEvent(event) + + select { + case e := <-b.(*batch).events: + assert.Equal(t, event, e, "Pushed event should be the same as the popped one") + default: + t.Error("Event was not pushed to the events channel") } +} - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - time.Sleep(5 * time.Second) - }() - wg.Wait() +func TestPushItem(t *testing.T) { + b := NewBatch() + item := &protobuf.ItemDto{} + b.pushItem(item) + + select { + case i := <-b.(*batch).items: + assert.Equal(t, item, i, "Pushed item should be the same as the popped one") + default: + t.Error("Item was not pushed to the items channel") + } } diff --git a/internal/core/core.go b/internal/core/core.go index 3f1e9af..10bd46f 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -20,6 +20,6 @@ var ( AccountId string DataSourceId string Platform string = "go" - SdkVersion string = "1.0.0" + SdkVersion string = "1.0.1" InitializedSuccessfully bool ) diff --git a/internal/core/event.go b/internal/core/event.go index d9f4bad..a69ab8e 100644 --- a/internal/core/event.go +++ b/internal/core/event.go @@ -37,7 +37,22 @@ type EventBuilder struct { Attributes map[string]interface{} } -func (b EventBuilder) BuildCustomEvent() { +func BuildCustomEvent(builder *EventBuilder) { + event := buildCustomEvent(builder) + sendOrStoreEvent(bInst, event) +} + +func BuildUserLoginEvent(builder *EventBuilder) { + event := buildUserLoginEvent(builder) + sendOrStoreEvent(bInst, event) +} + +func BuildItemEvent(builder *EventBuilder) { + event := buildItemEvent(builder) + sendOrStoreItem(bInst, event) +} + +func buildCustomEvent(builder *EventBuilder) *protobuf.EventV3Dto { event := &protobuf.EventV3Dto{ ProjectKey: AccountId, DataSourceId: DataSourceId, @@ -46,27 +61,22 @@ func (b EventBuilder) BuildCustomEvent() { } event.EventType = protobuf.EventType_CUSTOM - event.EventName = b.EventName + event.EventName = builder.EventName - timestamp := b.getEventTime() + timestamp := builder.getEventTime() event.Timestamp = timestamp event.SendTime = timestamp - event.DeviceId = b.getAnonymousId() - event.UserId = b.getLoginUserId() - event.UserKey = b.getLoginUserKey() - event.Attributes = b.getAttributes() - - if BatchEnable { - batch.pushEvent(event) - } else { - sendEvent(event) - } + event.DeviceId = builder.getAnonymousId() + event.UserId = builder.getLoginUserId() + event.UserKey = builder.getLoginUserKey() + event.Attributes = builder.getAttributes() logger.Debug("BuildCustomEvent", "event", event.String()) + return event } -func (b EventBuilder) BuildUserLoginEvent() { +func buildUserLoginEvent(builder *EventBuilder) *protobuf.EventV3Dto { event := &protobuf.EventV3Dto{ ProjectKey: AccountId, DataSourceId: DataSourceId, @@ -76,41 +86,47 @@ func (b EventBuilder) BuildUserLoginEvent() { event.EventType = protobuf.EventType_LOGIN_USER_ATTRIBUTES - timestamp := b.getEventTime() + timestamp := builder.getEventTime() event.Timestamp = timestamp event.SendTime = timestamp - event.DeviceId = b.getAnonymousId() - event.UserId = b.getLoginUserId() - event.UserKey = b.getLoginUserKey() - event.Attributes = b.getAttributes() - - if BatchEnable { - batch.pushEvent(event) - } else { - sendEvent(event) - } + event.DeviceId = builder.getAnonymousId() + event.UserId = builder.getLoginUserId() + event.UserKey = builder.getLoginUserKey() + event.Attributes = builder.getAttributes() logger.Debug("BuildUserLoginEvent", "event", event.String()) + return event } -func (b EventBuilder) BuildItemEvent() { +func buildItemEvent(builder *EventBuilder) *protobuf.ItemDto { event := &protobuf.ItemDto{ ProjectKey: AccountId, DataSourceId: DataSourceId, } - event.Id = b.getItemId() - event.Key = b.getItemKey() - event.Attributes = b.getAttributes() + event.Id = builder.getItemId() + event.Key = builder.getItemKey() + event.Attributes = builder.getAttributes() + + logger.Debug("BuildItemEvent", "event", event.String()) + return event +} +func sendOrStoreEvent(batch Batch, event *protobuf.EventV3Dto) { + if BatchEnable { + batch.pushEvent(event) + } else { + sendEvent(event) + } +} + +func sendOrStoreItem(batch Batch, event *protobuf.ItemDto) { if BatchEnable { batch.pushItem(event) } else { sendItem(event) } - - logger.Debug("BuildItemEvent", "event", event.String()) } func (e *EventBuilder) getEventTime() int64 { diff --git a/internal/core/event_test.go b/internal/core/event_test.go new file mode 100644 index 0000000..87e69ae --- /dev/null +++ b/internal/core/event_test.go @@ -0,0 +1,302 @@ +// +// @license +// Copyright (C) 2024 Beijing Yishu Technology Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +import ( + "fmt" + "reflect" + "testing" + "time" + + "github.com/growingio/growingio-sdk-go/internal/protobuf" + "github.com/stretchr/testify/assert" +) + +func TestBuildCustomEvent(t *testing.T) { + AccountId = "123456" + fmt.Sprintf("%d", time.Now().UnixMilli()) + DataSourceId = "654321" + fmt.Sprintf("%d", time.Now().UnixMilli()) + SdkVersion = "1.0.0" + Platform = "go" + + builder := &EventBuilder{ + EventName: "name", + EventTime: time.Now().UnixMilli(), + AnonymousId: "BF3E1CE2-A96C-4A8A-A085-768E0C52F344", + LoginUserId: "userId123", + LoginUserKey: "userKey123", + Attributes: map[string]interface{}{"attribute1": "value1", "attribute2": "value2"}, + } + + event := buildCustomEvent(builder) + + assert.Equal(t, event.ProjectKey, AccountId, "ProjectKey should be correctly set") + assert.Equal(t, event.DataSourceId, DataSourceId, "DataSourceId should be correctly set") + assert.Equal(t, event.SdkVersion, SdkVersion, "SdkVersion should be correctly set") + assert.Equal(t, event.Platform, Platform, "Platform should be correctly set") + + assert.Equal(t, event.EventType, protobuf.EventType_CUSTOM, "EventType should be correctly set") + assert.Equal(t, event.EventName, builder.EventName, "EventName should be correctly set") + assert.Equal(t, event.Timestamp, builder.getEventTime(), "Timestamp should be correctly set") + assert.Equal(t, event.SendTime, builder.getEventTime(), "SendTime should be correctly set") + assert.Equal(t, event.DeviceId, builder.getAnonymousId(), "DeviceId should be correctly set") + assert.Equal(t, event.UserId, builder.getLoginUserId(), "UserId should be correctly set") + assert.Equal(t, event.UserKey, builder.getLoginUserKey(), "UserKey Key should be correctly set") + assert.Equal(t, event.Attributes, builder.getAttributes(), "Attributes should be correctly set") +} + +func TestBuildUserLoginEvent(t *testing.T) { + AccountId = "123456" + fmt.Sprintf("%d", time.Now().UnixMilli()) + DataSourceId = "654321" + fmt.Sprintf("%d", time.Now().UnixMilli()) + SdkVersion = "1.0.0" + Platform = "go" + + builder := &EventBuilder{ + EventTime: time.Now().UnixMilli(), + AnonymousId: "BF3E1CE2-A96C-4A8A-A085-768E0C52F344", + LoginUserId: "userId123", + LoginUserKey: "userKey123", + Attributes: map[string]interface{}{"attribute1": "value1", "attribute2": "value2"}, + } + + event := buildUserLoginEvent(builder) + + assert.Equal(t, event.ProjectKey, AccountId, "ProjectKey should be correctly set") + assert.Equal(t, event.DataSourceId, DataSourceId, "DataSourceId should be correctly set") + assert.Equal(t, event.SdkVersion, SdkVersion, "SdkVersion should be correctly set") + assert.Equal(t, event.Platform, Platform, "Platform should be correctly set") + + assert.Equal(t, event.EventType, protobuf.EventType_LOGIN_USER_ATTRIBUTES, "EventType should be correctly set") + assert.Equal(t, event.Timestamp, builder.getEventTime(), "Timestamp should be correctly set") + assert.Equal(t, event.SendTime, builder.getEventTime(), "SendTime should be correctly set") + assert.Equal(t, event.DeviceId, builder.getAnonymousId(), "DeviceId should be correctly set") + assert.Equal(t, event.UserId, builder.getLoginUserId(), "UserId should be correctly set") + assert.Equal(t, event.UserKey, builder.getLoginUserKey(), "UserKey Key should be correctly set") + assert.Equal(t, event.Attributes, builder.getAttributes(), "Attributes should be correctly set") +} + +func TestBuildItemEvent(t *testing.T) { + AccountId = "123456" + fmt.Sprintf("%d", time.Now().UnixMilli()) + DataSourceId = "654321" + fmt.Sprintf("%d", time.Now().UnixMilli()) + + builder := &EventBuilder{ + ItemId: "itemId123", + ItemKey: "itemKey123", + Attributes: map[string]interface{}{"attribute1": "value1", "attribute2": "value2"}, + } + + event := buildItemEvent(builder) + + assert.Equal(t, event.ProjectKey, AccountId, "ProjectKey should be correctly set") + assert.Equal(t, event.DataSourceId, DataSourceId, "DataSourceId should be correctly set") + + assert.Equal(t, event.Id, builder.getItemId(), "Item Id should be correctly set") + assert.Equal(t, event.Key, builder.getItemKey(), "Item Key should be correctly set") + assert.Equal(t, event.Attributes, builder.getAttributes(), "Attributes should be correctly set") +} + +func TestSendOrStoreEvent(t *testing.T) { + event := &protobuf.EventV3Dto{} + mockBatch := NewMockBatch(t) + + BatchEnable = true + mockBatch.EXPECT().pushEvent(event) + sendOrStoreEvent(mockBatch, event) + mockBatch.AssertExpectations(t) +} + +func TestSendOrStoreItem(t *testing.T) { + event := &protobuf.ItemDto{} + mockBatch := NewMockBatch(t) + + BatchEnable = true + mockBatch.EXPECT().pushItem(event) + sendOrStoreItem(mockBatch, event) + mockBatch.AssertExpectations(t) +} + +func TestGetEventTimeWhenSet(t *testing.T) { + eventTime := int64(1234567890) + e := &EventBuilder{EventTime: eventTime} + result := e.getEventTime() + assert.Equal(t, eventTime, result) +} + +func TestGetEventTimeWhenNoSet(t *testing.T) { + e := &EventBuilder{} + before := time.Now().UnixMilli() + result := e.getEventTime() + after := time.Now().UnixMilli() + assert.GreaterOrEqual(t, result, before) + assert.LessOrEqual(t, result, after) +} + +func TestGetAnonymousIdWhenSet(t *testing.T) { + anonymousId := "F06A848D-C1DD-4999-B646-82EB7578BBBB" + e := &EventBuilder{AnonymousId: anonymousId} + result := e.getAnonymousId() + assert.Equal(t, anonymousId, result) +} + +func TestGetAnonymousIdWhenNoSet(t *testing.T) { + e := &EventBuilder{} + result := e.getAnonymousId() + assert.Equal(t, "", result) +} + +func TestGetLoginUserIdWhenSet(t *testing.T) { + loginUserId := "userId123" + e := &EventBuilder{LoginUserId: loginUserId} + result := e.getLoginUserId() + assert.Equal(t, loginUserId, result) +} + +func TestGetLoginUserIdWhenNoSet(t *testing.T) { + e := &EventBuilder{} + result := e.getLoginUserId() + assert.Equal(t, "", result) +} + +func TestGetLoginUserKeyWhenSet(t *testing.T) { + loginUserKey := "userKey123" + e := &EventBuilder{LoginUserKey: loginUserKey} + result := e.getLoginUserKey() + assert.Equal(t, loginUserKey, result) +} + +func TestGetLoginUserKeyWhenNoSet(t *testing.T) { + e := &EventBuilder{} + result := e.getLoginUserKey() + assert.Equal(t, "", result) +} + +func TestGetItemIdWhenSet(t *testing.T) { + itemId := "item123" + e := &EventBuilder{ItemId: itemId} + result := e.getItemId() + assert.Equal(t, itemId, result) +} + +func TestGetItemIdWhenNoSet(t *testing.T) { + e := &EventBuilder{} + result := e.getItemId() + assert.Equal(t, "", result) +} + +func TestGetItemKeyWhenSet(t *testing.T) { + itemKey := "itemKey123" + e := &EventBuilder{ItemKey: itemKey} + result := e.getItemKey() + assert.Equal(t, itemKey, result) +} + +func TestGetItemKeyWhenNoSet(t *testing.T) { + e := &EventBuilder{} + result := e.getItemKey() + assert.Equal(t, "", result) +} + +func TestGetAttributes(t *testing.T) { + tests := []struct { + name string + attributes map[string]interface{} + expected map[string]string + }{ + { + name: "Single string value", + attributes: map[string]interface{}{ + "key1": "value1", + }, + expected: map[string]string{ + "key1": "value1", + }, + }, + { + name: "String slice", + attributes: map[string]interface{}{ + "key1": []string{"value1", "value2"}, + }, + expected: map[string]string{ + "key1": "value1||value2", + }, + }, + { + name: "Bool slice", + attributes: map[string]interface{}{ + "key1": []bool{true, false}, + }, + expected: map[string]string{ + "key1": "true||false", + }, + }, + { + name: "Int slice", + attributes: map[string]interface{}{ + "key1": []int{1, 2, 3}, + }, + expected: map[string]string{ + "key1": "1||2||3", + }, + }, + { + name: "Int32 slice", + attributes: map[string]interface{}{ + "key1": []int32{10, 20}, + }, + expected: map[string]string{ + "key1": "10||20", + }, + }, + { + name: "Float64 slice", + attributes: map[string]interface{}{ + "key1": []float64{1.1, 2.2, 3.3}, + }, + expected: map[string]string{ + "key1": "1.1||2.2||3.3", + }, + }, + { + name: "Map of strings", + attributes: map[string]interface{}{ + "key1": map[string]string{"subkey1": "subval1", "subkey2": "subval2"}, + }, + expected: map[string]string{ + "key1": `{"subkey1":"subval1","subkey2":"subval2"}`, + }, + }, + { + name: "Interface slice", + attributes: map[string]interface{}{ + "key1": []interface{}{"val1", 123, true}, + }, + expected: map[string]string{ + "key1": "val1||123||true", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &EventBuilder{Attributes: tt.attributes} + got := e.getAttributes() + + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("getAttributes() = %v, expected %v", got, tt.expected) + } + }) + } +} diff --git a/internal/core/mock_batch_test.go b/internal/core/mock_batch_test.go new file mode 100644 index 0000000..b23de98 --- /dev/null +++ b/internal/core/mock_batch_test.go @@ -0,0 +1,148 @@ +// +// @license +// Copyright (C) 2024 Beijing Yishu Technology Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// Code generated by mockery v2.46.3. DO NOT EDIT. + +package core + +import ( + protobuf "github.com/growingio/growingio-sdk-go/internal/protobuf" + mock "github.com/stretchr/testify/mock" +) + +// MockBatch is an autogenerated mock type for the Batch type +type MockBatch struct { + mock.Mock +} + +type MockBatch_Expecter struct { + mock *mock.Mock +} + +func (_m *MockBatch) EXPECT() *MockBatch_Expecter { + return &MockBatch_Expecter{mock: &_m.Mock} +} + +// pop provides a mock function with given fields: +func (_m *MockBatch) pop() { + _m.Called() +} + +// MockBatch_pop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'pop' +type MockBatch_pop_Call struct { + *mock.Call +} + +// pop is a helper method to define mock.On call +func (_e *MockBatch_Expecter) pop() *MockBatch_pop_Call { + return &MockBatch_pop_Call{Call: _e.mock.On("pop")} +} + +func (_c *MockBatch_pop_Call) Run(run func()) *MockBatch_pop_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockBatch_pop_Call) Return() *MockBatch_pop_Call { + _c.Call.Return() + return _c +} + +func (_c *MockBatch_pop_Call) RunAndReturn(run func()) *MockBatch_pop_Call { + _c.Call.Return(run) + return _c +} + +// pushEvent provides a mock function with given fields: event +func (_m *MockBatch) pushEvent(event *protobuf.EventV3Dto) { + _m.Called(event) +} + +// MockBatch_pushEvent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'pushEvent' +type MockBatch_pushEvent_Call struct { + *mock.Call +} + +// pushEvent is a helper method to define mock.On call +// - event *protobuf.EventV3Dto +func (_e *MockBatch_Expecter) pushEvent(event interface{}) *MockBatch_pushEvent_Call { + return &MockBatch_pushEvent_Call{Call: _e.mock.On("pushEvent", event)} +} + +func (_c *MockBatch_pushEvent_Call) Run(run func(event *protobuf.EventV3Dto)) *MockBatch_pushEvent_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*protobuf.EventV3Dto)) + }) + return _c +} + +func (_c *MockBatch_pushEvent_Call) Return() *MockBatch_pushEvent_Call { + _c.Call.Return() + return _c +} + +func (_c *MockBatch_pushEvent_Call) RunAndReturn(run func(*protobuf.EventV3Dto)) *MockBatch_pushEvent_Call { + _c.Call.Return(run) + return _c +} + +// pushItem provides a mock function with given fields: item +func (_m *MockBatch) pushItem(item *protobuf.ItemDto) { + _m.Called(item) +} + +// MockBatch_pushItem_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'pushItem' +type MockBatch_pushItem_Call struct { + *mock.Call +} + +// pushItem is a helper method to define mock.On call +// - item *protobuf.ItemDto +func (_e *MockBatch_Expecter) pushItem(item interface{}) *MockBatch_pushItem_Call { + return &MockBatch_pushItem_Call{Call: _e.mock.On("pushItem", item)} +} + +func (_c *MockBatch_pushItem_Call) Run(run func(item *protobuf.ItemDto)) *MockBatch_pushItem_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*protobuf.ItemDto)) + }) + return _c +} + +func (_c *MockBatch_pushItem_Call) Return() *MockBatch_pushItem_Call { + _c.Call.Return() + return _c +} + +func (_c *MockBatch_pushItem_Call) RunAndReturn(run func(*protobuf.ItemDto)) *MockBatch_pushItem_Call { + _c.Call.Return(run) + return _c +} + +// NewMockBatch creates a new instance of MockBatch. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockBatch(t interface { + mock.TestingT + Cleanup(func()) +}) *MockBatch { + mock := &MockBatch{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/core/mock_request_test.go b/internal/core/mock_request_test.go new file mode 100644 index 0000000..a1b5e7a --- /dev/null +++ b/internal/core/mock_request_test.go @@ -0,0 +1,137 @@ +// +// @license +// Copyright (C) 2024 Beijing Yishu Technology Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// Code generated by mockery v2.46.3. DO NOT EDIT. + +package core + +import mock "github.com/stretchr/testify/mock" + +// MockRequest is an autogenerated mock type for the Request type +type MockRequest struct { + mock.Mock +} + +type MockRequest_Expecter struct { + mock *mock.Mock +} + +func (_m *MockRequest) EXPECT() *MockRequest_Expecter { + return &MockRequest_Expecter{mock: &_m.Mock} +} + +// prepare provides a mock function with given fields: +func (_m *MockRequest) prepare() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for prepare") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockRequest_prepare_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'prepare' +type MockRequest_prepare_Call struct { + *mock.Call +} + +// prepare is a helper method to define mock.On call +func (_e *MockRequest_Expecter) prepare() *MockRequest_prepare_Call { + return &MockRequest_prepare_Call{Call: _e.mock.On("prepare")} +} + +func (_c *MockRequest_prepare_Call) Run(run func()) *MockRequest_prepare_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockRequest_prepare_Call) Return(_a0 error) *MockRequest_prepare_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockRequest_prepare_Call) RunAndReturn(run func() error) *MockRequest_prepare_Call { + _c.Call.Return(run) + return _c +} + +// send provides a mock function with given fields: +func (_m *MockRequest) send() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for send") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockRequest_send_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'send' +type MockRequest_send_Call struct { + *mock.Call +} + +// send is a helper method to define mock.On call +func (_e *MockRequest_Expecter) send() *MockRequest_send_Call { + return &MockRequest_send_Call{Call: _e.mock.On("send")} +} + +func (_c *MockRequest_send_Call) Run(run func()) *MockRequest_send_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockRequest_send_Call) Return(_a0 error) *MockRequest_send_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockRequest_send_Call) RunAndReturn(run func() error) *MockRequest_send_Call { + _c.Call.Return(run) + return _c +} + +// NewMockRequest creates a new instance of MockRequest. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockRequest(t interface { + mock.TestingT + Cleanup(func()) +}) *MockRequest { + mock := &MockRequest{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/core/request.go b/internal/core/request.go index 5560070..3aa4daf 100644 --- a/internal/core/request.go +++ b/internal/core/request.go @@ -22,7 +22,6 @@ import ( "net/http" "time" - "github.com/golang/snappy" "github.com/growingio/growingio-sdk-go/internal/protobuf" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" @@ -30,7 +29,12 @@ import ( logger "github.com/growingio/growingio-sdk-go/internal/logger" ) -type Request struct { +type Request interface { + prepare() error + send() error +} + +type request struct { URL string Headers map[string]string Body []byte @@ -42,86 +46,55 @@ type RequestUrl struct { Item string } -var Urls RequestUrl -var RequestTimeout int = 5 +var ( + Urls RequestUrl + RequestTimeout int = 5 +) func sendItem(item *protobuf.ItemDto) { - items := &protobuf.ItemDtoList{ + itemList := &protobuf.ItemDtoList{ Values: []*protobuf.ItemDto{item}, } - makeRequest(items, Urls.Item) + req := NewRequest(itemList, Urls.Item) + sendRequest(req) } func sendItems(items []*protobuf.ItemDto) { itemList := &protobuf.ItemDtoList{ Values: items, } - makeRequest(itemList, Urls.Item) + req := NewRequest(itemList, Urls.Item) + sendRequest(req) } func sendEvent(event *protobuf.EventV3Dto) { eventList := &protobuf.EventV3List{ Values: []*protobuf.EventV3Dto{event}, } - makeRequest(eventList, Urls.Collect) + req := NewRequest(eventList, Urls.Collect) + sendRequest(req) } func sendEvents(events []*protobuf.EventV3Dto) { eventList := &protobuf.EventV3List{ Values: events, } - makeRequest(eventList, Urls.Collect) -} - -type Pipe func(*Request) error - -type PipeManager struct { - pipes []Pipe -} - -var pipe *PipeManager - -func getPipeManager() *PipeManager { - if pipe == nil { - pipe = &PipeManager{} - pipe.addPipe(compress) - pipe.addPipe(encrypt) - } - return pipe -} - -func (pm *PipeManager) addPipe(pipe Pipe) { - pm.pipes = append(pm.pipes, pipe) + req := NewRequest(eventList, Urls.Collect) + sendRequest(req) } -func (pm *PipeManager) execute(req *Request) error { - for _, pipe := range pm.pipes { - if err := pipe(req); err != nil { - return err - } +func sendRequest(req Request) { + if err := req.prepare(); err != nil { + logger.Error(err, "request prepare failed") + return } - return nil -} - -func compress(req *Request) error { - compressed := snappy.Encode(nil, req.Body) - req.Body = compressed - req.Headers["X-Compress-Codec"] = "2" - return nil -} -func encrypt(req *Request) error { - hint := byte(req.Timestamp % 256) - encrypted := make([]byte, len(req.Body)) - for i, b := range req.Body { - encrypted[i] = b ^ hint + if err := req.send(); err != nil { + logger.Error(err, "request send failed") } - req.Body = encrypted - req.Headers["X-Crypt-Codec"] = "1" - return nil } -func makeRequest(m protoreflect.ProtoMessage, baseURL string) { +func NewRequest(m protoreflect.ProtoMessage, baseURL string) Request { timestamp := time.Now().UnixMilli() timestampString := fmt.Sprintf("%d", timestamp) url := baseURL + "?stm=" + timestampString @@ -131,27 +104,22 @@ func makeRequest(m protoreflect.ProtoMessage, baseURL string) { headers["X-Timestamp"] = timestampString body, _ := proto.Marshal(m) - req := &Request{ + return &request{ URL: url, Headers: headers, Body: body, Timestamp: timestamp, } +} +func (req *request) prepare() error { pm := getPipeManager() - if err := pm.execute(req); err != nil { - logger.Error(err, "make request failed") - return - } - - if err := sendRequest(req); err != nil { - logger.Error(err, "send request failed") - } + return pm.execute(req) } -func sendRequest(req *Request) error { +func (req *request) send() error { httpReq, _ := http.NewRequest(http.MethodPost, req.URL, bytes.NewBuffer(req.Body)) - logger.Debug("make request", "url", httpReq.URL) + logger.Debug("create a new request", "url", httpReq.URL) for key, value := range req.Headers { httpReq.Header.Set(key, value) @@ -166,6 +134,6 @@ func sendRequest(req *Request) error { } defer resp.Body.Close() - logger.Debug("get response", "status", resp.Status) + logger.Debug("receive response", "status", resp.Status) return nil } diff --git a/internal/core/request_pipe.go b/internal/core/request_pipe.go new file mode 100644 index 0000000..e0e879c --- /dev/null +++ b/internal/core/request_pipe.go @@ -0,0 +1,67 @@ +// +// @license +// Copyright (C) 2024 Beijing Yishu Technology Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +import "github.com/golang/snappy" + +type Pipe func(*request) error + +type PipeManager struct { + pipes []Pipe +} + +var pipe *PipeManager + +func getPipeManager() *PipeManager { + if pipe == nil { + pipe = &PipeManager{} + pipe.addPipe(compress) + pipe.addPipe(encrypt) + } + return pipe +} + +func (pm *PipeManager) addPipe(pipe Pipe) { + pm.pipes = append(pm.pipes, pipe) +} + +func (pm *PipeManager) execute(req *request) error { + for _, pipe := range pm.pipes { + if err := pipe(req); err != nil { + return err + } + } + return nil +} + +func compress(req *request) error { + compressed := snappy.Encode(nil, req.Body) + req.Body = compressed + req.Headers["X-Compress-Codec"] = "2" + return nil +} + +func encrypt(req *request) error { + hint := byte(req.Timestamp % 256) + encrypted := make([]byte, len(req.Body)) + for i, b := range req.Body { + encrypted[i] = b ^ hint + } + req.Body = encrypted + req.Headers["X-Crypt-Codec"] = "1" + return nil +} diff --git a/internal/core/request_pipe_test.go b/internal/core/request_pipe_test.go new file mode 100644 index 0000000..e21a8f5 --- /dev/null +++ b/internal/core/request_pipe_test.go @@ -0,0 +1,69 @@ +// +// @license +// Copyright (C) 2024 Beijing Yishu Technology Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +import ( + "bytes" + "testing" + "time" + + "github.com/golang/snappy" +) + +func TestPipeManager_Execute(t *testing.T) { + txt := `The Go programming language is an open source project to make programmers more productive. + Go is expressive, concise, clean, and efficient. Its concurrency mechanisms make it easy to write + programs that get the most out of multicore and networked machines, while its novel type system + enables flexible and modular program construction. Go compiles quickly to machine code yet has + the convenience of garbage collection and the power of run-time reflection. It's a fast, statically + typed, compiled language that feels like a dynamically typed, interpreted language.` + req := &request{ + Body: []byte(txt), + Headers: make(map[string]string), + Timestamp: time.Now().UnixMilli(), + } + + pm := getPipeManager() + err := pm.execute(req) + if err != nil { + t.Fatalf("Expected no error, but got %v", err) + } + + // first decrypt + hint := byte(req.Timestamp % 256) + decrypted := make([]byte, len(req.Body)) + for i, b := range req.Body { + decrypted[i] = b ^ hint + } + + // then decompress + decompressed, err := snappy.Decode(nil, decrypted) + if err != nil { + t.Fatalf("Expected no error, but got %v", err) + } + if !bytes.Equal(decompressed, []byte(txt)) { + t.Fatal("Expected decrypted and decompressed body to be the same as the original, but not") + } + + if req.Headers["X-Compress-Codec"] != "2" { + t.Fatalf("Expected header X-Compress-Codec to be '2', but got '%s'", req.Headers["X-Compress-Codec"]) + } + + if req.Headers["X-Crypt-Codec"] != "1" { + t.Fatalf("Expected header X-Crypt-Codec to be '1', but got '%s'", req.Headers["X-Crypt-Codec"]) + } +} diff --git a/internal/core/request_test.go b/internal/core/request_test.go new file mode 100644 index 0000000..1347afa --- /dev/null +++ b/internal/core/request_test.go @@ -0,0 +1,33 @@ +// +// @license +// Copyright (C) 2024 Beijing Yishu Technology Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package core + +import ( + "testing" +) + +func TestSendRequest(t *testing.T) { + mockRequest := NewMockRequest(t) + + mockRequest.EXPECT().send().Return(nil).NotBefore( + mockRequest.EXPECT().prepare().Return(nil).Call, + ) + + sendRequest(mockRequest) + + mockRequest.AssertExpectations(t) +} diff --git a/internal/logger/LICENSE b/internal/logger/LICENSE new file mode 100644 index 0000000..54ce071 --- /dev/null +++ b/internal/logger/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/internal/logger/cur_goroutine.go b/internal/logger/cur_goroutine.go new file mode 100644 index 0000000..75053c0 --- /dev/null +++ b/internal/logger/cur_goroutine.go @@ -0,0 +1,140 @@ +// This file's codes are from `golang.org/x/net/http2/gotrack.go` +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package logger + +import ( + "bytes" + "errors" + "fmt" + "runtime" + "strconv" + "sync" +) + +var goroutineSpace = []byte("goroutine ") + +func curGoroutineID() uint64 { + bp := littleBuf.Get().(*[]byte) + defer littleBuf.Put(bp) + b := *bp + b = b[:runtime.Stack(b, false)] + // Parse the 4707 out of "goroutine 4707 [" + b = bytes.TrimPrefix(b, goroutineSpace) + i := bytes.IndexByte(b, ' ') + if i < 0 { + fmt.Printf("No space found in %q", b) + return 0 + } + b = b[:i] + n, err := parseUintBytes(b, 10, 64) + if err != nil { + fmt.Printf("Failed to parse goroutine ID out of %q: %v", b, err) + return 0 + } + return n +} + +var littleBuf = sync.Pool{ + New: func() interface{} { + buf := make([]byte, 64) + return &buf + }, +} + +// parseUintBytes is like strconv.ParseUint, but using a []byte. +func parseUintBytes(s []byte, base int, bitSize int) (n uint64, err error) { + var cutoff, maxVal uint64 + + if bitSize == 0 { + bitSize = int(strconv.IntSize) + } + + s0 := s + switch { + case len(s) < 1: + err = strconv.ErrSyntax + goto Error + + case 2 <= base && base <= 36: + // valid base; nothing to do + + case base == 0: + // Look for octal, hex prefix. + switch { + case s[0] == '0' && len(s) > 1 && (s[1] == 'x' || s[1] == 'X'): + base = 16 + s = s[2:] + if len(s) < 1 { + err = strconv.ErrSyntax + goto Error + } + case s[0] == '0': + base = 8 + default: + base = 10 + } + + default: + err = errors.New("invalid base " + strconv.Itoa(base)) + goto Error + } + + n = 0 + cutoff = cutoff64(base) + maxVal = 1<= base { + n = 0 + err = strconv.ErrSyntax + goto Error + } + + if n >= cutoff { + // n*base overflows + n = 1<<64 - 1 + err = strconv.ErrRange + goto Error + } + n *= uint64(base) + + n1 := n + uint64(v) + if n1 < n || n1 > maxVal { + // n+v overflows + n = 1<<64 - 1 + err = strconv.ErrRange + goto Error + } + n = n1 + } + + return n, nil + +Error: + return n, &strconv.NumError{Func: "ParseUint", Num: string(s0), Err: err} +} + +// Return the first number n such that n*base >= 1<<64. +func cutoff64(base int) uint64 { + if base < 2 { + return 0 + } + return (1<<64-1)/uint64(base) + 1 +} diff --git a/internal/logger/glog.go b/internal/logger/glog.go new file mode 100644 index 0000000..1fea06e --- /dev/null +++ b/internal/logger/glog.go @@ -0,0 +1,89 @@ +// +// @license +// Copyright (C) 2024 Beijing Yishu Technology Co., Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logger + +import "sync" + +const ( + debugLogLevel int = 8 + infoLogLevel int = 4 + warnLogLevel int = 1 + errorLogLevel int = 0 +) + +type Logger interface { + setLogLevel(logLevel int) + debug(msg string, keysAndValues ...any) + info(msg string, keysAndValues ...any) + warn(msg string, keysAndValues ...any) + error_log(err error, msg string, keysAndValues ...any) +} + +var ( + glog Logger + logLevel int + once sync.Once +) + +func NewLogger() { + once.Do(func() { + glog = newLogger() + }) +} + +func SetLogLevel(level int) { + logLevel = level + glog.setLogLevel(level) +} + +func Debug(msg string, keysAndValues ...any) { + if glog == nil { + return + } + if logLevel == debugLogLevel { + keysAndValues = append([]any{"goroutineId", curGoroutineID()}, keysAndValues...) + } + glog.debug(msg, keysAndValues...) +} + +func Info(msg string, keysAndValues ...any) { + if glog == nil { + return + } + if logLevel == debugLogLevel { + keysAndValues = append([]any{"goroutineId", curGoroutineID()}, keysAndValues...) + } + glog.info(msg, keysAndValues...) +} + +func Warn(msg string, keysAndValues ...any) { + if glog == nil { + return + } + if logLevel == debugLogLevel { + keysAndValues = append([]any{"goroutineId", curGoroutineID()}, keysAndValues...) + } + glog.warn(msg, keysAndValues...) +} + +func Error(err error, msg string, keysAndValues ...any) { + if glog == nil { + return + } + keysAndValues = append([]any{"goroutineId", curGoroutineID()}, keysAndValues...) + glog.error_log(err, msg, keysAndValues...) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 9fd7356..03d337e 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -17,65 +17,40 @@ package logger import ( - "fmt" stdlog "log" "os" - "runtime" - "sync" "github.com/go-logr/logr" "github.com/go-logr/stdr" ) -func SetLogLevel(logLevel int) { - stdr.SetVerbosity(logLevel) -} - -func Debug(msg string, keysAndValues ...any) { - combined := append([]any{"goroutineId", getGoroutineID()}, keysAndValues...) - sharedInstance().V(debugLogLevel).Info(msg, combined...) +type logger struct { + vendor logr.Logger } -func Info(msg string, keysAndValues ...any) { - combined := append([]any{"goroutineId", getGoroutineID()}, keysAndValues...) - sharedInstance().V(infoLogLevel).Info(msg, combined...) +func newLogger() Logger { + vendor := stdr.New(stdlog.New(os.Stderr, "[GrowingAnalytics] ", stdlog.LstdFlags|stdlog.Lmsgprefix)) + return &logger{ + vendor: vendor, + } } -func Warn(msg string, keysAndValues ...any) { - combined := append([]any{"goroutineId", getGoroutineID()}, keysAndValues...) - sharedInstance().V(warnLogLevel).Info(msg, combined...) +func (l *logger) setLogLevel(logLevel int) { + stdr.SetVerbosity(logLevel) } -func Error(err error, msg string, keysAndValues ...any) { - combined := append([]any{"goroutineId", getGoroutineID()}, keysAndValues...) - sharedInstance().Error(err, msg, combined...) +func (l *logger) debug(msg string, keysAndValues ...any) { + l.vendor.V(debugLogLevel).Info(msg, keysAndValues...) } -func getGoroutineID() uint64 { - var buf [64]byte - runtime.Stack(buf[:], false) - var id uint64 - fmt.Sscanf(string(buf[:]), "goroutine %d", &id) - return id +func (l *logger) info(msg string, keysAndValues ...any) { + l.vendor.V(infoLogLevel).Info(msg, keysAndValues...) } -const ( - debugLogLevel int = 8 - infoLogLevel int = 4 - warnLogLevel int = 1 - errorLogLevel int = 0 -) - -var ( - logger logr.Logger - once sync.Once -) - -func initLogger() { - logger = stdr.New(stdlog.New(os.Stderr, "[GrowingAnalytics] ", stdlog.LstdFlags|stdlog.Lmsgprefix)) +func (l *logger) warn(msg string, keysAndValues ...any) { + l.vendor.V(warnLogLevel).Info(msg, keysAndValues...) } -func sharedInstance() logr.Logger { - once.Do(initLogger) - return logger +func (l *logger) error_log(err error, msg string, keysAndValues ...any) { + l.vendor.Error(err, msg, keysAndValues...) }