@vincent119/go-copilot-rules 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent_agy/INSTALLATION.md +786 -0
- package/.agent_agy/README.md +243 -0
- package/.agent_agy/SKILLS_INDEX.md +516 -0
- package/.agent_agy/rules/go-core.copilot-instructions.md +251 -0
- package/.agent_agy/skills/go-api-design/SKILL.md +535 -0
- package/.agent_agy/skills/go-ci-tooling/SKILL.md +533 -0
- package/.agent_agy/skills/go-configuration/SKILL.md +609 -0
- package/.agent_agy/skills/go-database/SKILL.md +412 -0
- package/.agent_agy/skills/go-ddd/SKILL.md +374 -0
- package/.agent_agy/skills/go-dependency-injection/SKILL.md +546 -0
- package/.agent_agy/skills/go-domain-events/SKILL.md +525 -0
- package/.agent_agy/skills/go-examples/SKILL.md +690 -0
- package/.agent_agy/skills/go-graceful-shutdown/SKILL.md +708 -0
- package/.agent_agy/skills/go-grpc/SKILL.md +484 -0
- package/.agent_agy/skills/go-http-advanced/SKILL.md +494 -0
- package/.agent_agy/skills/go-observability/SKILL.md +684 -0
- package/.agent_agy/skills/go-testing-advanced/SKILL.md +573 -0
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/cli/install.js +344 -0
- package/package.json +47 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: go-domain-events
|
|
3
|
+
description: |
|
|
4
|
+
Go Domain Events 實作:事件定義、發布模式、Event Bus、Outbox Pattern、冪等處理、
|
|
5
|
+
Event Sourcing 基礎、非同步處理。
|
|
6
|
+
|
|
7
|
+
**適用場景**:DDD Domain Events、實作 Event Bus、Outbox Pattern、冪等性設計、
|
|
8
|
+
非同步事件處理、微服務溝通、Event Sourcing、解耦業務邏輯。
|
|
9
|
+
|
|
10
|
+
**關鍵字**:domain events, event bus, outbox pattern, idempotency, event sourcing,
|
|
11
|
+
async events, event-driven, message queue, event publishing, saga pattern
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Go Domain Events 實作規範
|
|
15
|
+
|
|
16
|
+
> **相關 Skills**:本規範建議搭配 `go-ddd`(領域驅動設計)與 `go-database`(Outbox Pattern)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Domain Events 概念
|
|
21
|
+
|
|
22
|
+
### 為何需要 Domain Events?
|
|
23
|
+
|
|
24
|
+
- **解耦**:業務邏輯不直接依賴下游服務
|
|
25
|
+
- **可擴展**:新增 Event Handler 不影響既有程式碼
|
|
26
|
+
- **可測試**:可獨立測試事件發布與處理邏輯
|
|
27
|
+
- **審計**:事件即為業務操作的歷史記錄
|
|
28
|
+
|
|
29
|
+
### 事件 vs 命令
|
|
30
|
+
|
|
31
|
+
| 類型 | 時態 | 範例 | 可拒絕 |
|
|
32
|
+
|--------|--------|---------------------------|--------|
|
|
33
|
+
| 命令 | 未來式 | CreateUser、PlaceOrder | 是 |
|
|
34
|
+
| 事件 | 過去式 | UserCreated、OrderPlaced | 否 |
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 事件定義
|
|
39
|
+
|
|
40
|
+
### 基本事件結構
|
|
41
|
+
|
|
42
|
+
```go
|
|
43
|
+
package domain
|
|
44
|
+
|
|
45
|
+
import (
|
|
46
|
+
"time"
|
|
47
|
+
"github.com/google/uuid"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// DomainEvent 介面
|
|
51
|
+
type DomainEvent interface {
|
|
52
|
+
EventID() string
|
|
53
|
+
EventType() string
|
|
54
|
+
AggregateID() string
|
|
55
|
+
OccurredAt() time.Time
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// BaseEvent 基礎實作
|
|
59
|
+
type BaseEvent struct {
|
|
60
|
+
ID string `json:"id"`
|
|
61
|
+
Type string `json:"type"`
|
|
62
|
+
AggrID string `json:"aggregate_id"`
|
|
63
|
+
Timestamp time.Time `json:"occurred_at"`
|
|
64
|
+
Version int `json:"version"`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func NewBaseEvent(eventType string, aggregateID string, version int) BaseEvent {
|
|
68
|
+
return BaseEvent{
|
|
69
|
+
ID: uuid.New().String(),
|
|
70
|
+
Type: eventType,
|
|
71
|
+
AggrID: aggregateID,
|
|
72
|
+
Timestamp: time.Now(),
|
|
73
|
+
Version: version,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func (e BaseEvent) EventID() string { return e.ID }
|
|
78
|
+
func (e BaseEvent) EventType() string { return e.Type }
|
|
79
|
+
func (e BaseEvent) AggregateID() string { return e.AggrID }
|
|
80
|
+
func (e BaseEvent) OccurredAt() time.Time { return e.Timestamp }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 具體事件範例
|
|
84
|
+
|
|
85
|
+
```go
|
|
86
|
+
// UserCreated 事件
|
|
87
|
+
type UserCreated struct {
|
|
88
|
+
BaseEvent
|
|
89
|
+
Email string `json:"email"`
|
|
90
|
+
Name string `json:"name"`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func NewUserCreated(userID string, email string, name string) *UserCreated {
|
|
94
|
+
return &UserCreated{
|
|
95
|
+
BaseEvent: NewBaseEvent("UserCreated", userID, 1),
|
|
96
|
+
Email: email,
|
|
97
|
+
Name: name,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// OrderPlaced 事件
|
|
102
|
+
type OrderPlaced struct {
|
|
103
|
+
BaseEvent
|
|
104
|
+
OrderID string `json:"order_id"`
|
|
105
|
+
CustomerID string `json:"customer_id"`
|
|
106
|
+
Amount float64 `json:"amount"`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func NewOrderPlaced(orderID string, customerID string, amount float64) *OrderPlaced {
|
|
110
|
+
return &OrderPlaced{
|
|
111
|
+
BaseEvent: NewBaseEvent("OrderPlaced", orderID, 1),
|
|
112
|
+
OrderID: orderID,
|
|
113
|
+
CustomerID: customerID,
|
|
114
|
+
Amount: amount,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Event Bus
|
|
122
|
+
|
|
123
|
+
### In-Memory Event Bus(簡單場景)
|
|
124
|
+
|
|
125
|
+
```go
|
|
126
|
+
package eventbus
|
|
127
|
+
|
|
128
|
+
import (
|
|
129
|
+
"context"
|
|
130
|
+
"sync"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
type EventHandler func(ctx context.Context, event DomainEvent) error
|
|
134
|
+
|
|
135
|
+
type EventBus struct {
|
|
136
|
+
mu sync.RWMutex
|
|
137
|
+
handlers map[string][]EventHandler
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func NewEventBus() *EventBus {
|
|
141
|
+
return &EventBus{
|
|
142
|
+
handlers: make(map[string][]EventHandler),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Subscribe 註冊事件處理器
|
|
147
|
+
func (eb *EventBus) Subscribe(eventType string, handler EventHandler) {
|
|
148
|
+
eb.mu.Lock()
|
|
149
|
+
defer eb.mu.Unlock()
|
|
150
|
+
|
|
151
|
+
eb.handlers[eventType] = append(eb.handlers[eventType], handler)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Publish 發布事件
|
|
155
|
+
func (eb *EventBus) Publish(ctx context.Context, event DomainEvent) error {
|
|
156
|
+
eb.mu.RLock()
|
|
157
|
+
handlers := eb.handlers[event.EventType()]
|
|
158
|
+
eb.mu.RUnlock()
|
|
159
|
+
|
|
160
|
+
for _, handler := range handlers {
|
|
161
|
+
// 同步處理(簡單場景)
|
|
162
|
+
if err := handler(ctx, event); err != nil {
|
|
163
|
+
return fmt.Errorf("handler failed: %w", err)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return nil
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### 使用範例
|
|
172
|
+
|
|
173
|
+
```go
|
|
174
|
+
func main() {
|
|
175
|
+
bus := eventbus.NewEventBus()
|
|
176
|
+
|
|
177
|
+
// 註冊 Handler
|
|
178
|
+
bus.Subscribe("UserCreated", func(ctx context.Context, event DomainEvent) error {
|
|
179
|
+
e := event.(*UserCreated)
|
|
180
|
+
log.Printf("User created: %s (%s)", e.Name, e.Email)
|
|
181
|
+
return nil
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
bus.Subscribe("UserCreated", func(ctx context.Context, event DomainEvent) error {
|
|
185
|
+
e := event.(*UserCreated)
|
|
186
|
+
// 發送歡迎信
|
|
187
|
+
return emailService.SendWelcomeEmail(e.Email)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// 發布事件
|
|
191
|
+
event := NewUserCreated("123", "john@example.com", "John")
|
|
192
|
+
if err := bus.Publish(context.Background(), event); err != nil {
|
|
193
|
+
log.Fatal(err)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Outbox Pattern
|
|
201
|
+
|
|
202
|
+
### 為何需要 Outbox Pattern?
|
|
203
|
+
|
|
204
|
+
**問題**:資料庫事務與訊息發布不是原子操作
|
|
205
|
+
```go
|
|
206
|
+
// ❌ 問題:若 Publish 失敗,使用者已建立但事件未發出
|
|
207
|
+
tx.Exec("INSERT INTO users ...")
|
|
208
|
+
tx.Commit()
|
|
209
|
+
eventBus.Publish(userCreatedEvent) // 可能失敗
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**解決方案**:將事件寫入資料庫(Outbox Table),再由後台程序發送
|
|
213
|
+
|
|
214
|
+
### Outbox Table Schema
|
|
215
|
+
|
|
216
|
+
```sql
|
|
217
|
+
CREATE TABLE outbox_events (
|
|
218
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
219
|
+
event_type VARCHAR(100) NOT NULL,
|
|
220
|
+
aggregate_id VARCHAR(100) NOT NULL,
|
|
221
|
+
payload JSONB NOT NULL,
|
|
222
|
+
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
223
|
+
published_at TIMESTAMPTZ,
|
|
224
|
+
retry_count INT DEFAULT 0,
|
|
225
|
+
error TEXT
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
CREATE INDEX idx_outbox_events_unpublished
|
|
229
|
+
ON outbox_events (occurred_at)
|
|
230
|
+
WHERE published_at IS NULL;
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### 寫入 Outbox(Transaction)
|
|
234
|
+
|
|
235
|
+
```go
|
|
236
|
+
func (r *UserRepository) Create(ctx context.Context, tx *sql.Tx, user *User) error {
|
|
237
|
+
// 1. 插入 User
|
|
238
|
+
_, err := tx.ExecContext(ctx, `
|
|
239
|
+
INSERT INTO users (id, email, name) VALUES ($1, $2, $3)
|
|
240
|
+
`, user.ID, user.Email, user.Name)
|
|
241
|
+
if err != nil {
|
|
242
|
+
return fmt.Errorf("insert user: %w", err)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 2. 寫入 Outbox Event(同一個 Transaction)
|
|
246
|
+
event := NewUserCreated(user.ID, user.Email, user.Name)
|
|
247
|
+
payload, _ := json.Marshal(event)
|
|
248
|
+
|
|
249
|
+
_, err = tx.ExecContext(ctx, `
|
|
250
|
+
INSERT INTO outbox_events (event_type, aggregate_id, payload, occurred_at)
|
|
251
|
+
VALUES ($1, $2, $3, $4)
|
|
252
|
+
`, event.EventType(), event.AggregateID(), payload, event.OccurredAt())
|
|
253
|
+
if err != nil {
|
|
254
|
+
return fmt.Errorf("insert outbox: %w", err)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return nil
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Outbox Publisher(Background Worker)
|
|
262
|
+
|
|
263
|
+
```go
|
|
264
|
+
type OutboxPublisher struct {
|
|
265
|
+
db *sql.DB
|
|
266
|
+
publisher EventPublisher // 例如 Kafka、RabbitMQ
|
|
267
|
+
logger *zap.Logger
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
func (op *OutboxPublisher) Run(ctx context.Context) {
|
|
271
|
+
ticker := time.NewTicker(5 * time.Second)
|
|
272
|
+
defer ticker.Stop()
|
|
273
|
+
|
|
274
|
+
for {
|
|
275
|
+
select {
|
|
276
|
+
case <-ctx.Done():
|
|
277
|
+
return
|
|
278
|
+
case <-ticker.C:
|
|
279
|
+
if err := op.processOutbox(ctx); err != nil {
|
|
280
|
+
op.logger.Error("process outbox failed", zap.Error(err))
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
func (op *OutboxPublisher) processOutbox(ctx context.Context) error {
|
|
287
|
+
// 1. 查詢未發布的事件(限制筆數避免記憶體爆炸)
|
|
288
|
+
rows, err := op.db.QueryContext(ctx, `
|
|
289
|
+
SELECT id, event_type, payload
|
|
290
|
+
FROM outbox_events
|
|
291
|
+
WHERE published_at IS NULL
|
|
292
|
+
ORDER BY occurred_at
|
|
293
|
+
LIMIT 100
|
|
294
|
+
FOR UPDATE SKIP LOCKED
|
|
295
|
+
`)
|
|
296
|
+
if err != nil {
|
|
297
|
+
return fmt.Errorf("query outbox: %w", err)
|
|
298
|
+
}
|
|
299
|
+
defer rows.Close()
|
|
300
|
+
|
|
301
|
+
for rows.Next() {
|
|
302
|
+
var id string
|
|
303
|
+
var eventType string
|
|
304
|
+
var payload []byte
|
|
305
|
+
|
|
306
|
+
if err := rows.Scan(&id, &eventType, &payload); err != nil {
|
|
307
|
+
return fmt.Errorf("scan row: %w", err)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 2. 發布事件到 Message Queue
|
|
311
|
+
if err := op.publisher.Publish(ctx, eventType, payload); err != nil {
|
|
312
|
+
// 更新重試次數與錯誤訊息
|
|
313
|
+
op.db.ExecContext(ctx, `
|
|
314
|
+
UPDATE outbox_events
|
|
315
|
+
SET retry_count = retry_count + 1, error = $1
|
|
316
|
+
WHERE id = $2
|
|
317
|
+
`, err.Error(), id)
|
|
318
|
+
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 3. 標記為已發布
|
|
323
|
+
_, err := op.db.ExecContext(ctx, `
|
|
324
|
+
UPDATE outbox_events SET published_at = NOW() WHERE id = $1
|
|
325
|
+
`, id)
|
|
326
|
+
if err != nil {
|
|
327
|
+
return fmt.Errorf("update outbox: %w", err)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return nil
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## 冪等性處理
|
|
338
|
+
|
|
339
|
+
### 為何需要冪等性?
|
|
340
|
+
|
|
341
|
+
**問題**:同一事件可能被處理多次(網路重試、系統故障)
|
|
342
|
+
|
|
343
|
+
### 冪等性 Key
|
|
344
|
+
|
|
345
|
+
```go
|
|
346
|
+
// Event 包含冪等性 Key
|
|
347
|
+
type UserCreated struct {
|
|
348
|
+
BaseEvent
|
|
349
|
+
Email string `json:"email"`
|
|
350
|
+
Name string `json:"name"`
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
func (e *UserCreated) IdempotencyKey() string {
|
|
354
|
+
// 使用 EventID 作為冪等性 Key
|
|
355
|
+
return e.EventID()
|
|
356
|
+
}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Processed Events Table
|
|
360
|
+
|
|
361
|
+
```sql
|
|
362
|
+
CREATE TABLE processed_events (
|
|
363
|
+
event_id VARCHAR(100) PRIMARY KEY,
|
|
364
|
+
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
CREATE INDEX idx_processed_events_processed_at ON processed_events (processed_at);
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Handler 實作
|
|
371
|
+
|
|
372
|
+
```go
|
|
373
|
+
func (h *UserEventHandler) Handle(ctx context.Context, event *UserCreated) error {
|
|
374
|
+
// 1. 檢查是否已處理
|
|
375
|
+
var exists bool
|
|
376
|
+
err := h.db.QueryRowContext(ctx, `
|
|
377
|
+
SELECT EXISTS(SELECT 1 FROM processed_events WHERE event_id = $1)
|
|
378
|
+
`, event.EventID()).Scan(&exists)
|
|
379
|
+
if err != nil {
|
|
380
|
+
return fmt.Errorf("check processed: %w", err)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if exists {
|
|
384
|
+
h.logger.Info("event already processed", zap.String("event_id", event.EventID()))
|
|
385
|
+
return nil // 冪等:直接返回成功
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 2. 處理事件
|
|
389
|
+
tx, err := h.db.BeginTx(ctx, nil)
|
|
390
|
+
if err != nil {
|
|
391
|
+
return fmt.Errorf("begin tx: %w", err)
|
|
392
|
+
}
|
|
393
|
+
defer tx.Rollback()
|
|
394
|
+
|
|
395
|
+
// 業務邏輯
|
|
396
|
+
if err := h.sendWelcomeEmail(ctx, event.Email); err != nil {
|
|
397
|
+
return fmt.Errorf("send email: %w", err)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// 3. 記錄為已處理(同一個 Transaction)
|
|
401
|
+
_, err = tx.ExecContext(ctx, `
|
|
402
|
+
INSERT INTO processed_events (event_id) VALUES ($1)
|
|
403
|
+
`, event.EventID())
|
|
404
|
+
if err != nil {
|
|
405
|
+
return fmt.Errorf("insert processed: %w", err)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return tx.Commit()
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## Event Sourcing 基礎
|
|
415
|
+
|
|
416
|
+
### 概念
|
|
417
|
+
|
|
418
|
+
**Event Sourcing**:不儲存當前狀態,而是儲存所有歷史事件,透過重播事件重建狀態
|
|
419
|
+
|
|
420
|
+
### Event Store Schema
|
|
421
|
+
|
|
422
|
+
```sql
|
|
423
|
+
CREATE TABLE event_store (
|
|
424
|
+
sequence_number BIGSERIAL PRIMARY KEY,
|
|
425
|
+
stream_id VARCHAR(100) NOT NULL,
|
|
426
|
+
event_type VARCHAR(100) NOT NULL,
|
|
427
|
+
event_data JSONB NOT NULL,
|
|
428
|
+
metadata JSONB,
|
|
429
|
+
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
CREATE INDEX idx_event_store_stream_id ON event_store (stream_id, sequence_number);
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### Append Event
|
|
436
|
+
|
|
437
|
+
```go
|
|
438
|
+
func (es *EventStore) Append(ctx context.Context, streamID string, event DomainEvent) error {
|
|
439
|
+
payload, _ := json.Marshal(event)
|
|
440
|
+
|
|
441
|
+
_, err := es.db.ExecContext(ctx, `
|
|
442
|
+
INSERT INTO event_store (stream_id, event_type, event_data, occurred_at)
|
|
443
|
+
VALUES ($1, $2, $3, $4)
|
|
444
|
+
`, streamID, event.EventType(), payload, event.OccurredAt())
|
|
445
|
+
|
|
446
|
+
return err
|
|
447
|
+
}
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### Replay Events(重建狀態)
|
|
451
|
+
|
|
452
|
+
```go
|
|
453
|
+
func (es *EventStore) Replay(ctx context.Context, streamID string) ([]DomainEvent, error) {
|
|
454
|
+
rows, err := es.db.QueryContext(ctx, `
|
|
455
|
+
SELECT event_type, event_data
|
|
456
|
+
FROM event_store
|
|
457
|
+
WHERE stream_id = $1
|
|
458
|
+
ORDER BY sequence_number
|
|
459
|
+
`, streamID)
|
|
460
|
+
if err != nil {
|
|
461
|
+
return nil, err
|
|
462
|
+
}
|
|
463
|
+
defer rows.Close()
|
|
464
|
+
|
|
465
|
+
var events []DomainEvent
|
|
466
|
+
for rows.Next() {
|
|
467
|
+
var eventType string
|
|
468
|
+
var payload []byte
|
|
469
|
+
|
|
470
|
+
if err := rows.Scan(&eventType, &payload); err != nil {
|
|
471
|
+
return nil, err
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// 反序列化事件(需要 Event Registry)
|
|
475
|
+
event, err := deserializeEvent(eventType, payload)
|
|
476
|
+
if err != nil {
|
|
477
|
+
return nil, err
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
events = append(events, event)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return events, nil
|
|
484
|
+
}
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
---
|
|
488
|
+
|
|
489
|
+
## 檢查清單
|
|
490
|
+
|
|
491
|
+
**事件定義**
|
|
492
|
+
- [ ] 事件名稱使用過去式(UserCreated、OrderPlaced)
|
|
493
|
+
- [ ] 事件包含 EventID、Timestamp、AggregateID
|
|
494
|
+
- [ ] 事件不可變(Immutable)
|
|
495
|
+
- [ ] 事件包含業務所需的所有資訊
|
|
496
|
+
|
|
497
|
+
**Event Bus**
|
|
498
|
+
- [ ] 支援多個 Handler 訂閱同一事件
|
|
499
|
+
- [ ] Handler 失敗不影響其他 Handler
|
|
500
|
+
- [ ] 考慮非同步處理(避免阻塞主流程)
|
|
501
|
+
- [ ] 使用 Context 傳遞取消訊號
|
|
502
|
+
|
|
503
|
+
**Outbox Pattern**
|
|
504
|
+
- [ ] 事件與業務操作在同一個 Transaction
|
|
505
|
+
- [ ] 使用 `FOR UPDATE SKIP LOCKED` 避免競爭
|
|
506
|
+
- [ ] 定期清理已發布的舊事件
|
|
507
|
+
- [ ] 監控 Outbox 堆積情況
|
|
508
|
+
|
|
509
|
+
**冪等性**
|
|
510
|
+
- [ ] 事件包含唯一 ID(冪等性 Key)
|
|
511
|
+
- [ ] Handler 處理前檢查是否已處理
|
|
512
|
+
- [ ] 記錄已處理事件到資料庫
|
|
513
|
+
- [ ] 定期清理舊的已處理記錄
|
|
514
|
+
|
|
515
|
+
**Event Sourcing**
|
|
516
|
+
- [ ] 事件為唯一資料來源(Single Source of Truth)
|
|
517
|
+
- [ ] 不可刪除或修改事件
|
|
518
|
+
- [ ] 支援快照(Snapshot)避免重播太多事件
|
|
519
|
+
- [ ] 謹慎設計事件結構(難以變更)
|
|
520
|
+
|
|
521
|
+
**測試**
|
|
522
|
+
- [ ] 測試事件發布與訂閱
|
|
523
|
+
- [ ] 測試 Handler 冪等性
|
|
524
|
+
- [ ] 測試事件重播邏輯
|
|
525
|
+
- [ ] 模擬 Handler 失敗場景
|