<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Tony Bai &#187; Mock</title>
	<atom:link href="http://tonybai.com/tag/mock/feed/" rel="self" type="application/rss+xml" />
	<link>https://tonybai.com</link>
	<description>一个程序员的心路历程</description>
	<lastBuildDate>Wed, 29 Apr 2026 23:21:55 +0000</lastBuildDate>
	<language>en</language>
	<sy:updatePeriod>hourly</sy:updatePeriod>
	<sy:updateFrequency>1</sy:updateFrequency>
	<generator>http://wordpress.org/?v=3.2.1</generator>
		<item>
		<title>Go 代码设计的“第一天原则”：一份能让你少走五年弯路的实战模式清单</title>
		<link>https://tonybai.com/2026/04/24/go-code-design-day-one-principle-practical-patterns-list/</link>
		<comments>https://tonybai.com/2026/04/24/go-code-design-day-one-principle-practical-patterns-list/#comments</comments>
		<pubDate>Thu, 23 Apr 2026 23:13:22 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[CodeDesign]]></category>
		<category><![CDATA[ConfigurationManagement]]></category>
		<category><![CDATA[ContextManagement]]></category>
		<category><![CDATA[DayOnePrinciple]]></category>
		<category><![CDATA[DefensiveProgramming]]></category>
		<category><![CDATA[DependencyInjection]]></category>
		<category><![CDATA[EngineeringPractices]]></category>
		<category><![CDATA[ErrorHandling]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[GoLanguage]]></category>
		<category><![CDATA[Go语言]]></category>
		<category><![CDATA[GracefulShutdown]]></category>
		<category><![CDATA[InterfaceDesign]]></category>
		<category><![CDATA[metrics]]></category>
		<category><![CDATA[MinimumContract]]></category>
		<category><![CDATA[Mock]]></category>
		<category><![CDATA[observability]]></category>
		<category><![CDATA[prometheus]]></category>
		<category><![CDATA[Refactoring]]></category>
		<category><![CDATA[StructuredLogging]]></category>
		<category><![CDATA[TechnicalDebt]]></category>
		<category><![CDATA[Testability]]></category>
		<category><![CDATA[TypedErrors]]></category>
		<category><![CDATA[上下文管理]]></category>
		<category><![CDATA[代码设计]]></category>
		<category><![CDATA[代码重构]]></category>
		<category><![CDATA[优雅停机]]></category>
		<category><![CDATA[依赖注入]]></category>
		<category><![CDATA[可测试性]]></category>
		<category><![CDATA[可观测性]]></category>
		<category><![CDATA[工程实践]]></category>
		<category><![CDATA[技术债]]></category>
		<category><![CDATA[接口设计]]></category>
		<category><![CDATA[最小契约]]></category>
		<category><![CDATA[第一天原则]]></category>
		<category><![CDATA[类型化错误]]></category>
		<category><![CDATA[结构化日志]]></category>
		<category><![CDATA[配置管理]]></category>
		<category><![CDATA[错误处理]]></category>
		<category><![CDATA[防御性编程]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=6221</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2026/04/24/go-code-design-day-one-principle-practical-patterns-list 大家好，我是Tony Bai。 世界读书日送福利活动火热进行中，点击这里留言参与，赢取属于你的幸运！ 每一个 Go 开发者，大概都经历过这样的心路历程： 项目启动初期，为了追求“快”，我们怎么方便怎么来。配置到处写，数据库连接随手建，错误日志直接 fmt.Println。我们安慰自己：“先跑起来，以后再重构。” 结果呢？ 半年后，项目变成了一座摇摇欲坠的“屎山”。配置散落在几十个文件里，改一个端口号要动十个地方；数据库连接池因为没关，把连接数打满；线上出了 Bug，日志里只有一行孤零零的 record not found，查个问题比登天还难。 技术债，就像滚雪球，你越是假装看不见，它就滚得越大。 这时候，你的内心肯定在呐喊：有没有一些在Go项目刚创建时期就应该知道的Go代码模式，可以让我在项目的“第一天”，就建立起一套健壮、可维护、可观测的骨架呢！ 有的！ 我将这套方法论，称为 Go 语言架构的“第一天原则”。掌握它，足以让你在Go 代码设计的道路上，少走五年弯路。 这些原则，没有一条是关于炫技的复杂设计模式。 今天，我们就来逐条硬核拆解这些原则，并用可运行的 Go 代码，手把手教你如何将它们落地。 原则一：配置集中解析，依赖显式注入 这是所有“混乱”的根源。如果你的代码里，到处都是 os.Getenv(“DB_HOST”)，那你的项目已经走在了通往地狱的路上。 反模式： 在某个业务函数的深处，为了连一下 Redis，临时去读环境变量。这使得你的函数与外部环境强耦合，极难进行单元测试。 第一天原则： 在 main 函数中，一次性完成所有配置的解析和校验，然后通过构造函数，将“配置好”的依赖（如数据库连接池），以“接口”的形式，显式地注入到需要的服务中。 【Go 代码实战】 // https://go.dev/play/p/CrGDShmoFFJ package main import ( "context" "database/sql" "fmt" "log" "net/http" "os" _ "github.com/lib/pq" ) [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/2026/go-code-design-day-one-principle-practical-patterns-list-1.png" alt="" /></p>
<p><a href="https://tonybai.com/2026/04/24/go-code-design-day-one-principle-practical-patterns-list">本文永久链接</a> &#8211; https://tonybai.com/2026/04/24/go-code-design-day-one-principle-practical-patterns-list</p>
<p>大家好，我是Tony Bai。</p>
<blockquote>
<p>世界读书日送福利活动火热进行中，<a href="https://mp.weixin.qq.com/s/tSboOai1CE9IJBNg7BMPCg">点击这里</a>留言参与，赢取属于你的幸运！</p>
</blockquote>
<p>每一个 Go 开发者，大概都经历过这样的心路历程：</p>
<p>项目启动初期，为了追求“快”，我们怎么方便怎么来。配置到处写，数据库连接随手建，错误日志直接 fmt.Println。我们安慰自己：“先跑起来，以后再重构。”</p>
<p>结果呢？</p>
<p>半年后，项目变成了一座摇摇欲坠的“屎山”。配置散落在几十个文件里，改一个端口号要动十个地方；数据库连接池因为没关，把连接数打满；线上出了 Bug，日志里只有一行孤零零的 record not found，查个问题比登天还难。</p>
<p><strong>技术债，就像滚雪球，你越是假装看不见，它就滚得越大。</strong></p>
<p>这时候，你的内心肯定在呐喊：有没有一些在Go项目刚创建时期就应该知道的Go代码模式，可以让我在项目的<strong>“第一天”</strong>，就建立起一套健壮、可维护、可观测的骨架呢！</p>
<p>有的！</p>
<p>我将这套方法论，称为 <strong>Go 语言架构的“第一天原则”</strong>。掌握它，足以让你在Go 代码设计的道路上，少走五年弯路。</p>
<p>这些原则，没有一条是关于炫技的复杂设计模式。</p>
<p>今天，我们就来逐条硬核拆解这些原则，并用可运行的 Go 代码，手把手教你如何将它们落地。</p>
<p><img src="https://tonybai.com/wp-content/uploads/2026/agentic-software-engineering-qr.png" alt="" /></p>
<h2>原则一：配置集中解析，依赖显式注入</h2>
<p>这是所有“混乱”的根源。如果你的代码里，到处都是 os.Getenv(“DB_HOST”)，那你的项目已经走在了通往地狱的路上。</p>
<p><strong>反模式：</strong></p>
<blockquote>
<p>在某个业务函数的深处，为了连一下 Redis，临时去读环境变量。这使得你的函数与外部环境强耦合，极难进行单元测试。</p>
</blockquote>
<p><strong>第一天原则：</strong></p>
<blockquote>
<p><strong>在 main 函数中，一次性完成所有配置的解析和校验，然后通过构造函数，将“配置好”的依赖（如数据库连接池），以“接口”的形式，显式地注入到需要的服务中。</strong></p>
</blockquote>
<p><strong>【Go 代码实战】</strong></p>
<pre><code class="go">// https://go.dev/play/p/CrGDShmoFFJ
package main

import (
    "context"
    "database/sql"
    "fmt"
    "log"
    "net/http"
    "os"

    _ "github.com/lib/pq"
)

type Config struct {
    DatabaseURL string
    ListenAddr  string
}

func loadConfig() Config {
    dbURL := os.Getenv("DATABASE_URL")
    if dbURL == "" {
        log.Fatal("DATABASE_URL is not set")
    }
    return Config{
        DatabaseURL: dbURL,
        ListenAddr:  ":8080",
    }
}

type UserRepo interface {
    GetUser(ctx context.Context, id int) (string, error)
}

type PostgresUserRepo struct {
    db *sql.DB
}

func (r *PostgresUserRepo) GetUser(ctx context.Context, id int) (string, error) {
    var name string
    err := r.db.QueryRowContext(ctx, "SELECT name FROM users WHERE id=$1", id).Scan(&amp;name)
    return name, err
}

func NewPostgresUserRepo(db *sql.DB) *PostgresUserRepo {
    return &amp;PostgresUserRepo{db: db}
}

type Server struct {
    repo UserRepo
}

func NewServer(repo UserRepo) *Server {
    return &amp;Server{repo: repo}
}

func (s *Server) HandleGetUser(w http.ResponseWriter, r *http.Request) {
    name, err := s.repo.GetUser(r.Context(), 1)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    fmt.Fprintf(w, "User: %s", name)
}

func main() {
    cfg := loadConfig()

    db, err := sql.Open("postgres", cfg.DatabaseURL)
    if err != nil {
        log.Fatalf("failed to connect to database: %v", err)
    }
    defer db.Close()

    repo := NewPostgresUserRepo(db)
    server := NewServer(repo)

    http.HandleFunc("/user", server.HandleGetUser)
    log.Printf("Server starting on %s", cfg.ListenAddr)
    log.Fatal(http.ListenAndServe(cfg.ListenAddr, nil))
}
</code></pre>
<p>这样一来，你的业务代码将变得极其纯粹，不依赖任何全局状态，测试时也可以轻松地 Mock 掉 UserRepo 接口。</p>
<h2>原则二：为可观测性而设计：结构化日志与 Metrics</h2>
<p>“不就是打个日志吗，fmt.Println 走起！”——这是毁掉一个项目最快的方式。</p>
<p><strong>反模式：</strong></p>
<blockquote>
<p>遇到错误，直接 log.Printf(“Error: %v”, err)。当线上出现几万条这样的日志时，你根本无法进行聚合、告警和趋势分析。</p>
</blockquote>
<p><strong>第一天原则：</strong></p>
<blockquote>
<p><strong>从第一天起，就引入结构化日志（如 log/slog 或 zap）。将所有关键信息（如 user_id, trace_id）作为独立的字段打印。同时，为关键业务指标（如缓存命中率、数据库查询延迟）埋入 Metrics。</strong></p>
</blockquote>
<p><strong>【Go 代码实战】</strong></p>
<pre><code class="go">// https://go.dev/play/p/h4_8a4nzCFx
package main

import (
    "log/slog"
    "net/http"
    "os"
    "time"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    cacheHits = prometheus.NewCounter(prometheus.CounterOpts{
        Name: "myapp_cache_hits_total",
        Help: "Total number of cache hits.",
    })
    dbQueryDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
        Name:    "myapp_db_query_duration_seconds",
        Help:    "Histogram of database query durations.",
        Buckets: prometheus.DefBuckets,
    })
)

func init() {
    prometheus.MustRegister(cacheHits, dbQueryDuration)
}

func handleRequest(w http.ResponseWriter, r *http.Request) {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    logger.Info("handling request", "method", r.Method, "path", r.URL.Path, "remote_addr", r.RemoteAddr)

    cacheHits.Inc()

    start := time.Now()
    time.Sleep(100 * time.Millisecond)
    duration := time.Since(start)
    dbQueryDuration.Observe(duration.Seconds())

    logger.Info("request handled successfully", "duration_ms", duration.Milliseconds())
    w.WriteHeader(http.StatusOK)
}

func main() {
    http.HandleFunc("/", handleRequest)
    http.Handle("/metrics", promhttp.Handler())

    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}
</code></pre>
<p>有了结构化日志和Metrics的加持，你的系统不再是一个“黑盒”。通过 Grafana 和 VictoriaLogs，你可以清晰地看到它的每一个内部状态，问题定位速度提升 10 倍。</p>
<h2>原则三：永不启动一个你不知道如何停止的 Goroutine</h2>
<p>这是 <a href="https://tonybai.com/2026/04/13/dave-cheney-goroutine-management-philosophy/">Dave Cheney 反复强调的血泪教训</a>。一个失控的 Goroutine，就是一个内存炸弹。</p>
<p><strong>反模式：</strong></p>
<blockquote>
<p>go doSomething()。然后呢？它什么时候结束？如果它卡住了怎么办？</p>
</blockquote>
<p><strong>第一天原则：</strong></p>
<blockquote>
<p><strong>任何一个需要长久运行的 Goroutine，都必须接受一个 context.Context 参数，并在 select 中监听 ctx.Done()。将所有后台 Goroutine 的生命周期，与你的应用程序生命周期绑定。</strong></p>
</blockquote>
<p><strong>【Go 代码实战】</strong></p>
<pre><code class="go">// https://go.dev/play/p/Fi1JUZfs4E-
package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func worker(ctx context.Context, id int) {
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()

    log.Printf("Worker %d started", id)
    for {
        select {
        case &lt;-ticker.C:
            log.Printf("Worker %d is doing work", id)
        case &lt;-ctx.Done():
            log.Printf("Worker %d is shutting down...", id)
            return
        }
    }
}

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()

    go worker(ctx, 1)

    &lt;-ctx.Done()

    log.Println("Main application shutting down.")
    time.Sleep(100 * time.Millisecond)
}
</code></pre>
<p>这样，你的应用就可以实现优雅停机（Graceful Shutdown），在 k8s 环境中滚动更新时，不会丢失任何正在处理的数据。</p>
<h2>原则四：为可测试性而设计，构建你的“数据靶场”</h2>
<p>在复杂的业务系统中，最难测试的不是“Happy Path”，而是各种千奇百怪的“Unhappy Paths”。</p>
<p><strong>第一天原则：</strong></p>
<blockquote>
<p><strong>为你的核心业务逻辑，构建独立的“数据生成器（Data Generators）”和“数据接收器（Sinks）”。在测试中，用内存中的模拟实现（Mocks）替换掉真实的外部依赖，从而能 100% 控制输入和验证输出。</strong></p>
</blockquote>
<p><strong>【Go 代码实战】</strong></p>
<pre><code class="go">// https://go.dev/play/p/NBsxpVE84Zb
package main

import (
    "context"
    "fmt"
    "sync"
    "testing"
)

type Order struct { ID int }

type OrderNotifier interface {
    Notify(ctx context.Context, order Order) error
}

type OrderProcessor struct {
    notifier OrderNotifier
}

func NewOrderProcessor(notifier OrderNotifier) *OrderProcessor {
    return &amp;OrderProcessor{notifier: notifier}
}

func (p *OrderProcessor) Process(ctx context.Context, order Order) error {
    return p.notifier.Notify(ctx, order)
}

type MockNotifier struct {
    mu        sync.Mutex
    Notified  []Order
    ShouldErr bool
}

func (m *MockNotifier) Notify(ctx context.Context, order Order) error {
    m.mu.Lock()
    defer m.mu.Unlock()
    if m.ShouldErr {
        return fmt.Errorf("mock notifier failed")
    }
    m.Notified = append(m.Notified, order)
    return nil
}

func TestOrderProcessor_Success(t *testing.T) {
    mockNotifier := &amp;MockNotifier{}
    processor := NewOrderProcessor(mockNotifier)
    order := Order{ID: 1}
    err := processor.Process(context.Background(), order)

    if err != nil {
        t.Errorf("expected no error, got %v", err)
    }
    if len(mockNotifier.Notified) != 1 || mockNotifier.Notified[0].ID != 1 {
        t.Errorf("notifier was not called correctly")
    }
}
</code></pre>
<p>遵守该原则后，你的单元测试将变得极快、极度稳定，并且能够 100% 覆盖所有你能想到的成功和失败分支。</p>
<h2>原则五：防御性编程，构建你的“代码防火墙”</h2>
<p>不相信任何外部输入。这是所有安全系统的第一性原理。</p>
<p><strong>第一天原则：</strong></p>
<blockquote>
<p><strong>在数据的入口处（如 HTTP Handler、gRPC Server），对所有传入的数据进行严格的、显式的校验（Validation）。只有通过了“安检”的干净数据，才能被允许进入系统的核心领域。</strong></p>
</blockquote>
<p><strong>【Go 代码实战(不完全示例)】</strong></p>
<pre><code class="go">package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "net/mail"
)

type CreateUserRequest struct {
    Username string json:"username"
    Email    string json:"email"
    Age      int    json:"age"
}

func (r *CreateUserRequest) Validate() error {
    if len(r.Username) &lt; 3 || len(r.Username) &gt; 20 {
        return fmt.Errorf("username length must be between 3 and 20")
    }
    if _, err := mail.ParseAddress(r.Email); err != nil {
        return fmt.Errorf("invalid email format: %w", err)
    }
    if r.Age &lt; 18 {
        return fmt.Errorf("user must be at least 18 years old")
    }
    return nil
}

func HandleCreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&amp;req); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }

    if err := req.Validate(); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    // processValidatedRequest(req) ...
    w.WriteHeader(http.StatusCreated)
}
</code></pre>
<p>这种防御可以让你的核心业务逻辑变得极其纯粹和安全，不再需要处理各种脏数据和边界情况。</p>
<blockquote>
<p>注：如果是服务器，外部(甚至是内部其他服务的)请求的速度也可能是一种“安全威胁”。因此无论是通过中间件，还是代码自行实现，<strong>限速机制</strong>是必不可少的。</p>
</blockquote>
<h2>原则六：错误包裹与类型化错误，让错误自己开口说话</h2>
<p>一个好的错误信息，应该像一份精准的“尸检报告”，而不是一句无意义的“他死了”。</p>
<p><strong>第一天原则：</strong></p>
<blockquote>
<p><strong>在错误产生的最底层，用 fmt.Errorf(“&#8230;: %w”, err) 详细包裹上下文。对于可预期的业务异常，定义成自定义的“类型化错误（Typed Errors）”，让上层逻辑可以通过 errors.As 进行精准的判断和处理。</strong></p>
</blockquote>
<p><strong>【Go 代码实战(不完全示例)】</strong></p>
<pre><code class="go">package main

import (
    "errors"
    "fmt"
    "net/http"
)

type ErrDuplicateUser struct { Email string }

func (e *ErrDuplicateUser) Error() string {
    return fmt.Sprintf("user with email %s already exists", e.Email)
}

func RegisterUser(email string) error {
    // 模拟数据库层返回一个已知类型的错误
    if email == "test@example.com" {
        return &amp;ErrDuplicateUser{Email: email}
    }
    return fmt.Errorf("db connection failed: %w", errors.New("timeout"))
}

func HandleRegister(w http.ResponseWriter, r *http.Request) {
    err := RegisterUser("test@example.com")
    if err != nil {
        var dupErr *ErrDuplicateUser
        if errors.As(err, &amp;dupErr) {
            http.Error(w, dupErr.Error(), http.StatusConflict)
        } else {
            // 对于未知的底层错误，只打日志，不暴露给用户
            slog.Error("failed to register user", "error", err)
            http.Error(w, "Internal server error", http.StatusInternalServerError)
        }
        return
    }
    w.WriteHeader(http.StatusCreated)
}
</code></pre>
<p>这样处理后，你的错误处理逻辑变得极其清晰和健壮，业务异常可以被优雅地反馈给用户。</p>
<h2>原则七：接口定义在消费侧，实现“最小化契约”</h2>
<p>这是 Go 语言最精髓、也最反直觉的一条哲学。</p>
<p><strong>第一天原则：</strong></p>
<blockquote>
<p><strong>永远不要在“定义侧”声明臃肿的接口。而是在“消费侧”，根据你真正需要的功能，定义一个只包含 1-2 个方法的“小接口”。</strong></p>
</blockquote>
<p><strong>【Go 代码实战（不完全示例）】</strong></p>
<pre><code class="go">// --- cache/cache.go ---
package cache
type BigCache struct {}
func (c *BigCache) Get(key string) (string, error) { /* ... */ }
func (c *BigCache) Set(key, val string) error     { /* ... */ }

// --- user/service.go ---
package user
import "fmt"
// 我们在 user 包里，只定义我们真正需要的小接口
type Getter interface {
    Get(key string) (string, error)
}
type UserService struct {
    cache Getter // 依赖的是小接口，而不是具体的 BigCache
}
func (s *UserService) GetUserName(id int) (string, error) {
    return s.cache.Get(fmt.Sprintf("user:%d", id))
}
</code></pre>
<p>示例代码中，你的 UserService 彻底与 BigCache 的具体实现解耦。在测试时可以极其轻松地传入 Mock 对象。</p>
<h2>小结：架构的本质，是与未来的自己对话</h2>
<p>看完上述的七条原则，你是否发现所有这些“第一天原则”都指向了一个共同的核心：<strong>可维护性（Maintainability）</strong>。</p>
<p>你在项目第一天偷的每一个懒，都会在未来的某一个深夜，变成一颗狠狠炸伤你或你同事的“技术地雷”。<strong>架构的本质，不是选择一个多么牛逼的框架，而是与未来的自己、未来的同事进行一场清晰、友好的对话。</strong></p>
<p>关掉这篇文章，打开你手头那个最新的项目。看看这 7 条原则，你触犯了哪几条？是时候，给你的代码库做一次“体检”了。</p>
<hr />
<p><strong>今日互动探讨：</strong></p>
<p>在你过去的 Go 项目中，踩过哪些因为早期“野蛮生长”而导致的设计大坑？除了这 7 条，你还有哪些“压箱底”的项目启动最佳实践？</p>
<p>欢迎在评论区分享你的血泪史与独家心法！</p>
<hr />
<p>还在为写 Agent 框架频频死循环、上下文爆炸而束手无策？我的新专栏 <strong>《<a href="http://gk.link/a/12IzL">从0 开始构建 Agent Harness</a>》</strong> 将带你：</p>
<ul>
<li>抛弃臃肿框架，回归“驾驭工程 (Harness Engineering)”的第一性原理</li>
<li>用 Go 语言手写 ReAct 循环、并发拦截与上下文压缩引擎等，复刻极简OpenClaw</li>
<li>构建坚不可摧的 Safety Middleware 与飞书人工审批防线</li>
<li>在底层实现 Token 成本审计、链路追踪与自动化跑分评估</li>
<li>从“调包侠”进化为掌控大模型边界的“AI 操作系统架构师”</li>
</ul>
<p>扫描下方二维码，开启从 0 开始构建Agent Harness 的实战之旅。</p>
<p><img src="https://tonybai.com/wp-content/uploads/2026/build-agent-harness-from-scratch-qr.png" alt="" /></p>
<hr />
<p><strong>原「Gopher部落」已重装升级为「Go &amp; AI 精进营」知识星球，快来加入星球，开启你的技术跃迁之旅吧！</strong></p>
<p>我们致力于打造一个高品质的 <strong>Go 语言深度学习</strong> 与 <strong>AI 应用探索</strong> 平台。在这里，你将获得：</p>
<ul>
<li><strong>体系化 Go 核心进阶内容:</strong> 深入「Go原理课」、「Go进阶课」、「Go避坑课」等独家深度专栏，夯实你的 Go 内功。</li>
<li><strong>前沿 Go+AI 实战赋能:</strong> 紧跟时代步伐，学习「Go+AI应用实战」、「Agent开发实战课」、「Agentic软件工程课」、「Claude Code开发工作流实战课」、「OpenClaw实战分享」等，掌握 AI 时代新技能。 </li>
<li><strong>星主 Tony Bai 亲自答疑:</strong> 遇到难题？星主第一时间为你深度解析，扫清学习障碍。</li>
<li><strong>高活跃 Gopher 交流圈:</strong> 与众多优秀 Gopher 分享心得、讨论技术，碰撞思想火花。</li>
<li><strong>独家资源与内容首发:</strong> 技术文章、课程更新、精选资源，第一时间触达。</li>
</ul>
<p>衷心希望「Go &amp; AI 精进营」能成为你学习、进步、交流的港湾。让我们在此相聚，享受技术精进的快乐！欢迎你的加入！</p>
<p><img src="http://image.tonybai.com/img/tonybai/gopher-and-ai-tribe-zsxq-small-card.jpg" alt="img{512x368}" /></p>
<hr />
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求，请扫描下方公众号二维码，与我私信联系。</p>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p style='text-align:left'>&copy; 2026, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2026/04/24/go-code-design-day-one-principle-practical-patterns-list/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>霸榜 GitHub 一周！Google 开源 ADK for Go，彻底终结 AI“炼丹”时代？</title>
		<link>https://tonybai.com/2025/11/24/google-adk-go-in-action/</link>
		<comments>https://tonybai.com/2025/11/24/google-adk-go-in-action/#comments</comments>
		<pubDate>Mon, 24 Nov 2025 00:15:27 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[ADK]]></category>
		<category><![CDATA[ADKforGo]]></category>
		<category><![CDATA[Agent]]></category>
		<category><![CDATA[AgentDevelopmentKit]]></category>
		<category><![CDATA[AI]]></category>
		<category><![CDATA[ClaudeCode]]></category>
		<category><![CDATA[CodeFirst]]></category>
		<category><![CDATA[Codereview]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[Gin]]></category>
		<category><![CDATA[Git]]></category>
		<category><![CDATA[github]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[Google]]></category>
		<category><![CDATA[Gopher]]></category>
		<category><![CDATA[gotest]]></category>
		<category><![CDATA[Go代码]]></category>
		<category><![CDATA[gRPC]]></category>
		<category><![CDATA[k8s]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Memory]]></category>
		<category><![CDATA[Mock]]></category>
		<category><![CDATA[Prompt]]></category>
		<category><![CDATA[Python]]></category>
		<category><![CDATA[session]]></category>
		<category><![CDATA[TonyBai]]></category>
		<category><![CDATA[workflowagents]]></category>
		<category><![CDATA[二进制文件]]></category>
		<category><![CDATA[云原生]]></category>
		<category><![CDATA[付费微专栏]]></category>
		<category><![CDATA[代码优先]]></category>
		<category><![CDATA[公众号]]></category>
		<category><![CDATA[函数]]></category>
		<category><![CDATA[单元测试]]></category>
		<category><![CDATA[可测试]]></category>
		<category><![CDATA[可维护]]></category>
		<category><![CDATA[可部署]]></category>
		<category><![CDATA[咖啡]]></category>
		<category><![CDATA[学伴]]></category>
		<category><![CDATA[工作流指挥家]]></category>
		<category><![CDATA[工作流自动化]]></category>
		<category><![CDATA[工程]]></category>
		<category><![CDATA[工程纪律]]></category>
		<category><![CDATA[并发]]></category>
		<category><![CDATA[开发范式]]></category>
		<category><![CDATA[开源]]></category>
		<category><![CDATA[微服务]]></category>
		<category><![CDATA[思维升级]]></category>
		<category><![CDATA[技能跃迁]]></category>
		<category><![CDATA[探索者]]></category>
		<category><![CDATA[智能体]]></category>
		<category><![CDATA[构建方法论]]></category>
		<category><![CDATA[消息]]></category>
		<category><![CDATA[深度长文]]></category>
		<category><![CDATA[炼丹时代]]></category>
		<category><![CDATA[版本管理]]></category>
		<category><![CDATA[生存指南]]></category>
		<category><![CDATA[短期记忆]]></category>
		<category><![CDATA[航海日志]]></category>
		<category><![CDATA[评估体系]]></category>
		<category><![CDATA[软件工程]]></category>
		<category><![CDATA[长期记忆]]></category>
		<category><![CDATA[霸榜]]></category>
		<category><![CDATA[静态编译]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=5431</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2025/11/24/google-adk-go-in-action 大家好，我是Tony Bai。 上周，我花了一个下午，仅仅是为了让一个Python写的Agent能稳定地调用我Go服务里的一个简单函数。在那一刻，看着屏幕上纠缠的gRPC、Python虚拟环境和混乱的日志，我脑海里只有一个念头：这不对劲，这绝对不是软件工程该有的样子！ 显然，不仅仅是我一个人在为此焦虑。 就在最近，一个名为 google/adk-go 的项目悄然开源，并迅速霸榜 GitHub Go 语言趋势榜长达一周之久！ 全球的 Gopher 似乎都在用脚投票，表达着同一个渴望：我们受够了“炼丹”，我们要回归工程！ 过去的一年，AI 的浪潮席卷了整个技术圈。我们 Gopher，作为构建云原生世界的中坚力量，看着 Python 社区在 AI 领域“杀”得热火朝天，心中或许都有一个共同的疑问： “这场 AI 的盛宴，我们 Gopher 的主菜在哪儿？” 我们习惯了用 goroutine 优雅地处理并发，用 channel 安全地传递消息，用静态编译的单个二进制文件征服任何服务器。我们是天生的“工程师”，我们信奉的是可测试、可维护、可部署的软件工程哲学。 然而，当我们尝试踏入 AI Agent 的世界时，却常常感觉自己像一个闯入了“炼丹房”的“机械师”。面对那些需要反复“吟唱咒语”（调 Prompt）、结果飘忽不定的“丹炉”（模型），我们不禁会问： 我的 Agent 行为不稳定，怎么写单元测试？ Prompt 稍微一改，整个“丹方”都可能失效，版本管理怎么做？ 我如何将这个“充满魔法”的 Python 脚本，与我现有的 Go 微服务体系优雅地集成，而不是变成一坨无法维护的“耦合怪”？ 这些问题，不是因为我们不懂 AI，而是因为我们太懂工程。我们厌倦了“炼丹”式的不确定性，我们渴望一种能将 AI 的强大能力，用严谨的工程纪律约束起来的解决方案。 现在，Google 亲自下场，为我们递来了“工程图纸”。 Google [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/2025/google-adk-go-in-action-1.png" alt="" /></p>
<p><a href="https://tonybai.com/2025/11/24/google-adk-go-in-action">本文永久链接</a> &#8211; https://tonybai.com/2025/11/24/google-adk-go-in-action</p>
<p>大家好，我是Tony Bai。</p>
<p>上周，我花了一个下午，仅仅是为了让一个Python写的Agent能稳定地调用我Go服务里的一个简单函数。在那一刻，看着屏幕上纠缠的gRPC、Python虚拟环境和混乱的日志，我脑海里只有一个念头：这不对劲，这绝对不是软件工程该有的样子！</p>
<p>显然，不仅仅是我一个人在为此焦虑。</p>
<p><strong>就在最近，一个名为 google/adk-go 的项目悄然开源，并迅速霸榜 GitHub Go 语言趋势榜长达一周之久！</strong> 全球的 Gopher 似乎都在用脚投票，表达着同一个渴望：我们受够了“炼丹”，我们要回归工程！</p>
<p>过去的一年，AI 的浪潮席卷了整个技术圈。我们 Gopher，作为构建云原生世界的中坚力量，看着 Python 社区在 AI 领域“杀”得热火朝天，心中或许都有一个共同的疑问：</p>
<p><strong>“这场 AI 的盛宴，我们 Gopher 的主菜在哪儿？”</strong></p>
<p>我们习惯了用 goroutine 优雅地处理并发，用 channel 安全地传递消息，用静态编译的单个二进制文件征服任何服务器。我们是天生的<strong>“工程师”</strong>，我们信奉的是<strong>可测试、可维护、可部署</strong>的软件工程哲学。</p>
<p>然而，当我们尝试踏入 AI Agent 的世界时，却常常感觉自己像一个闯入了“炼丹房”的“机械师”。面对那些需要反复“吟唱咒语”（调 Prompt）、结果飘忽不定的“丹炉”（模型），我们不禁会问：</p>
<ul>
<li><strong>我的 Agent 行为不稳定，怎么写单元测试？</strong></li>
<li><strong>Prompt 稍微一改，整个“丹方”都可能失效，版本管理怎么做？</strong></li>
<li><strong>我如何将这个“充满魔法”的 Python 脚本，与我现有的 Go 微服务体系优雅地集成，而不是变成一坨无法维护的“耦合怪”？</strong></li>
</ul>
<p>这些问题，不是因为我们不懂 AI，而是因为我们太懂<strong>工程</strong>。我们厌倦了“炼丹”式的不确定性，我们渴望一种能将 AI 的强大能力，<strong>用严谨的工程纪律约束起来</strong>的解决方案。</p>
<p><strong>现在，Google 亲自下场，为我们递来了“工程图纸”。</strong></p>
<p><img src="https://tonybai.com/wp-content/uploads/2025/paid/google-adk-in-action-qr.png" alt="" /></p>
<h2>Google ADK for Go：写给工程师的 AI Agent 开发框架</h2>
<p>这个霸榜的项目，全称是 <strong><a href="https://github.com/google/adk-go">Agent Development Kit (ADK) for Go</a></strong>。</p>
<p><img src="https://tonybai.com/wp-content/uploads/2025/google-adk-go-in-action-2.png" alt="" /></p>
<p>这不是又一个“玩具”或“研究性”框架。从它的设计理念中，我看到了一个清晰而坚定的信号——<strong>AI Agent 开发，正在从“炼丹”式的“艺术创作”，全面进入“工程化”的“工业生产”时代。</strong></p>
<p>而 ADK for Go 的核心哲学，与我们 Gopher 的信仰不谋而合，那就是——<strong>代码优先 (Code-First)</strong>。</p>
<ul>
<li><strong>你的 Agent，就是你的 Go 代码：</strong> 不再有晦涩的 YAML，不再有天书般的“链”，Agent 的所有逻辑、决策、工作流，都由你亲手编写的、地地道道的 Go 代码来定义。</li>
<li><strong>天生的可测试性：</strong> 你的 Agent 就是一个实现了 agent.Agent 接口的 struct。这意味着什么？你可以像测试任何 Go 代码一样，go test 走起！Mock 依赖、断言行为，所有你熟悉的工程实践，全部回归。</li>
<li><strong>Git 即版本管理：</strong> Agent 的每一次进化，都是一次清晰的 git commit。Code Review、版本回滚，一切都尽在掌握。</li>
<li><strong>云原生无缝集成：</strong> 它就是一个标准的 Go 模块，可以被无缝地集成到你的 Gin/gRPC 服务中，打包成一个极小的 Docker 镜像，部署到任何 K8s 集群。</li>
</ul>
<p><strong>这就是为什么它能霸榜 GitHub 的原因——它不是在教你如何更好地“调优 Prompt”，而是在教你如何用坚实的工程代码，去彻底终结那个不可控的“炼丹”时代。</strong></p>
<p>Google的adk-go，就是那座连接 Gopher 工程世界与 AI Agent 智能世界的桥梁。</p>
<p><img src="https://tonybai.com/wp-content/uploads/2025/google-adk-go-in-action-3.png" alt="" /></p>
<h2>和我一起，从零开始“造”一个真正的 AI Agent</h2>
<p>坦白说，ADK for Go 刚刚推出，市面上的教程几乎一片空白。文档虽有，但如何将其与真实的工程场景结合，如何理解其设计背后的权衡，如何避开那些必将遇到的“坑”——这些都需要有人去<strong>探索</strong>，去<strong>趟路</strong>。</p>
<p><strong>所以，我决定做这件事。</strong></p>
<p>我将以一个<strong>“学伴”</strong>和<strong>“探索者”</strong>的身份，推出我的全新付费微专栏：</p>
<p><strong>《Google ADK 实战：用 Go 构建可靠的AI Agent》</strong></p>
<p>在这个专栏里，我不会扮演一个无所不知的专家。相反，我会将我从零开始学习、实践、踩坑、顿悟的全过程，毫无保留地分享给你。</p>
<p>我们将一起，手把手地、<strong>从一个空 main.go 文件开始</strong>，完成一次令人兴奋的创造之旅：</p>
<ul>
<li>
<p><strong>第 1-2 讲：思维转变与灵魂注入</strong><br />
我们将彻底理解“代码优先”的哲学，拆解adk-go，了解其中的概念、架构和核心组件，并亲手定义出第一个实现了 agent.Agent 核心接口的智能体。</p>
</li>
<li>
<p><strong>第 3 讲：为 Agent 插上“手臂”：</strong> 让你的Agent能调用任何Go函数，像操作自己的手脚一样自如<br />
我们将学会 ADK 的“魔法”函数 functiontool.New，将一个普通的 Go 函数，零成本地转化为 Agent 可用的工具。</p>
</li>
<li>
<p><strong>第 4 讲：赋予 Agent “双核记忆”</strong><br />
我们将深入 session（短期记忆）和 memory（长期记忆），让我们的 Agent 能够理解上下文，并记起与你的历史交互。</p>
</li>
<li>
<p><strong>第 5 讲：从“单兵”到“军团”：</strong> 构建一个懂分工、会协作的Agent团队，自动化完成复杂任务<br />
我们将学习 workflowagents，通过编排多个专家 Agent，构建一个强大的“代码生成-审查-重构”自动化流水线。</p>
</li>
<li>
<p><strong>第 6 讲：从“原型”到“产品”</strong><br />
我们将为 Agent 建立科学的<strong>评估体系</strong>，并最终将其打包成 Docker 镜像，部署到通用的 Kubernetes 环境中。</p>
</li>
</ul>
<p>学完这个专栏，你将收获的，不仅是一个能跑起来的酷炫 AI 项目，更是一套<strong>可复用的、工程化的 AI Agent 构建方法论</strong>，以及在 AI 新浪潮中，属于我们 Gopher 的那份自信和底气。</p>
<h2>加入这场 Gopher 的 AI 工程化之旅</h2>
<p>这个微专栏，是我为你，也为我自己准备的一份“AI 时代 Gopher 生存指南”。它凝聚了我对 Go 工程哲学的理解，和我对 AI Agent 未来的全部热情。</p>
<p>微专栏共 <strong>6 篇深度长文</strong>，每一篇都是我亲手实践、细节满满的 step-by-step “航海日志”。</p>
<p>我没有设定一个高昂的价格，而是希望与更多志同道合的 Gopher 一起探索。所以，订阅这份专栏，<strong>仅需你一杯咖啡的诚意</strong>。</p>
<p>花一杯咖啡的时间，你或许能得到片刻的清醒；而用同样的价格投入到这里，我希望能为你带来一次<strong>思维的升级</strong>和<strong>技能的跃迁</strong>。</p>
<p><strong>点击<a href="https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MzIyNzM0MDk0Mg==&amp;action=getalbum&amp;album_id=4266729696274251779#wechat_redirect">这里</a>，或扫描二维码，立即加入。</strong></p>
<p><strong>让我们一起，用代码，构建智能。</strong></p>
<p><img src="https://tonybai.com/wp-content/uploads/2025/paid/google-adk-in-action-qr.png" alt="" /></p>
<p><strong>P.S.</strong> 如果你对 AI Agent、Go 语言或者这个微专栏有任何问题，欢迎在评论区留言，我们一起交流探讨！</p>
<hr />
<p>还在为“复制粘贴喂AI”而烦恼？我的新专栏 <strong>《<a href="http://gk.link/a/12EPd">AI原生开发工作流实战</a>》</strong> 将带你：</p>
<ul>
<li>告别低效，重塑开发范式</li>
<li>驾驭AI Agent(Claude Code)，实现工作流自动化</li>
<li>从“AI使用者”进化为规范驱动开发的“工作流指挥家”</li>
</ul>
<p>扫描下方二维码，开启你的AI原生开发之旅。</p>
<p><img src="https://tonybai.com/wp-content/uploads/2025/ai-native-dev-workflow-qr.png" alt="" /></p>
<hr />
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求，请扫描下方公众号二维码，与我私信联系。</p>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p style='text-align:left'>&copy; 2025, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2025/11/24/google-adk-go-in-action/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>你的 Go 测试，还停留在“演员对台词”吗？</title>
		<link>https://tonybai.com/2025/11/17/go-testing-journey/</link>
		<comments>https://tonybai.com/2025/11/17/go-testing-journey/#comments</comments>
		<pubDate>Mon, 17 Nov 2025 00:25:04 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[API]]></category>
		<category><![CDATA[ChaosEngineering]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[coverage]]></category>
		<category><![CDATA[dockercompose]]></category>
		<category><![CDATA[E2E]]></category>
		<category><![CDATA[FakeObject]]></category>
		<category><![CDATA[fuzzing]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[GoldenFiles]]></category>
		<category><![CDATA[gotest]]></category>
		<category><![CDATA[Go测试]]></category>
		<category><![CDATA[Go测试之道]]></category>
		<category><![CDATA[Go语言第一课]]></category>
		<category><![CDATA[Go语言进阶课]]></category>
		<category><![CDATA[handler]]></category>
		<category><![CDATA[httptest]]></category>
		<category><![CDATA[Mock]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[redis]]></category>
		<category><![CDATA[Service]]></category>
		<category><![CDATA[testcontainers]]></category>
		<category><![CDATA[TonyBai]]></category>
		<category><![CDATA[toxiproxy]]></category>
		<category><![CDATA[单元测试]]></category>
		<category><![CDATA[发布]]></category>
		<category><![CDATA[可靠性]]></category>
		<category><![CDATA[契约测试]]></category>
		<category><![CDATA[工程实践]]></category>
		<category><![CDATA[工程能力]]></category>
		<category><![CDATA[微服务]]></category>
		<category><![CDATA[数据库]]></category>
		<category><![CDATA[构建约束]]></category>
		<category><![CDATA[模糊测试]]></category>
		<category><![CDATA[测试]]></category>
		<category><![CDATA[测试金字塔]]></category>
		<category><![CDATA[混沌工程]]></category>
		<category><![CDATA[端到端测试]]></category>
		<category><![CDATA[表驱动]]></category>
		<category><![CDATA[覆盖率]]></category>
		<category><![CDATA[覆盖率分析]]></category>
		<category><![CDATA[重构]]></category>
		<category><![CDATA[集成测试]]></category>
		<category><![CDATA[韧性]]></category>
		<category><![CDATA[高级实践]]></category>
		<category><![CDATA[黄金文件]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=5393</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2025/11/17/go-testing-journey 大家好，我是Tony Bai。 我想请大家想象一个场景： 周五下午五点，你刚刚修复了一个看似无关紧要的 bug，怀着对周末的憧憬，合并了你的代码。CI/CD 流水线一片绿灯，部署顺利完成。 突然，运维在工作群里 @ 了你：“紧急！新版本上线后，核心的用户注册功能好像挂了！” 你心里猛地一沉，这个功能你根本没动过，只是修改了它依赖的一个底层工具函数。冷汗开始从额头渗出，你下意识地喃喃自语：“不可能啊，我的单元测试明明都通过了……” 这个场景，或许你我或多或少都经历过。它引出了一个直击所有工程师灵魂的问题：为什么我们辛辛苦苦写的测试，没能挡住这次线上事故？ 你的测试，是否也只是“看起来很美”？ 在深入探讨之前，不妨和我一起做个小小的“体检”，看看我们的测试代码是否也存在一些“亚健康”状态： “晴天”的信徒： 你的测试是否只覆盖了“阳光普照”的成功路径，却选择性地忽略了数据库连接失败、Redis 缓存击穿、下游 API 超时等“电闪雷鸣”的异常场景？ 脆弱的“模拟”大师： 你是否为了写测试而构建了庞大而脆弱的 Mock 王国？以至于每次重构核心逻辑，都意味着要重写一半的测试代码，让你对重构本身心生恐惧，技术债越积越多。 “发布”前的祈祷者： 当项目越来越大，你敢在没有一轮紧张的手动回归测试的情况下，自信地点击“发布”按钮吗？go test ./&#8230; 的漫长等待是否已经让你无法忍受？ 如果以上问题让你感同身受，那说明我们的测试体系，可能还停留在“演员在镜子前练习自己台词”的阶段。它能保证你自己的“台词”（单个函数）没问题，却无法保证你在“舞台”上（真实环境）与其他“演员”（数据库、缓存、API）的配合不出错。 而线上事故，往往就出在这些“接缝”之处。 真正的信心，源自体系化的“测试之道” 那么，如何构建一个能真正守护我们安稳度过每个周末的测试体系呢？答案不在于写更多的单元测试，而在于建立一个科学、分层、覆盖从已知到未知的自动化测试系统。 这不仅仅是一门教你写测试的课程。这是一门为你注入“持续交付信心”的工程实践课。 我将以一个贯穿始终的“短链接”实战项目为例，带你走过一条完整的进阶之路——从构建坚实的“测试金字塔”，到掌握前沿的“高级实践”。 在这门专栏里，你将获得什么？ 一套完整的 Go 测试“作战地图”: 我们将自底向上，系统性地构建单元测试、集成测试、契约测试和端到端测试，让你清晰地知道在何处写何种测试。 “驯服”外部依赖的终极武器: 我将手把手带你使用 Testcontainers，在测试代码中“一键”拉起真实的数据库和 Redis，彻底告别脆弱的 Mock 和不稳定的共享测试环境。 一个装满“黑魔法”的高级工具箱: 我们不会止步于基础。你还将学到： 如何用覆盖率 (Coverage) 分析工具为你的测试“查漏补缺”。 如何用模糊测试 (Fuzzing) 去探索人类思维难以触及的“未知”边界。 [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/2025/go-testing-journey-1.png" alt="" /></p>
<p><a href="https://tonybai.com/2025/11/17/go-testing-journey">本文永久链接</a> &#8211; https://tonybai.com/2025/11/17/go-testing-journey</p>
<p>大家好，我是Tony Bai。</p>
<p>我想请大家想象一个场景：</p>
<p>周五下午五点，你刚刚修复了一个看似无关紧要的 bug，怀着对周末的憧憬，合并了你的代码。CI/CD 流水线一片绿灯，部署顺利完成。</p>
<p>突然，运维在工作群里 @ 了你：“紧急！新版本上线后，核心的用户注册功能好像挂了！”</p>
<p>你心里猛地一沉，这个功能你根本没动过，只是修改了它依赖的一个底层工具函数。冷汗开始从额头渗出，你下意识地喃喃自语：“不可能啊，我的单元测试明明都通过了……”</p>
<p>这个场景，或许你我或多或少都经历过。它引出了一个直击所有工程师灵魂的问题：<strong>为什么我们辛辛苦苦写的测试，没能挡住这次线上事故？</strong></p>
<h2>你的测试，是否也只是“看起来很美”？</h2>
<p>在深入探讨之前，不妨和我一起做个小小的“体检”，看看我们的测试代码是否也存在一些“亚健康”状态：</p>
<ol>
<li><strong>“晴天”的信徒：</strong> 你的测试是否只覆盖了“阳光普照”的成功路径，却选择性地忽略了数据库连接失败、Redis 缓存击穿、下游 API 超时等“电闪雷鸣”的异常场景？</li>
<li><strong>脆弱的“模拟”大师：</strong> 你是否为了写测试而构建了庞大而脆弱的 Mock 王国？以至于每次重构核心逻辑，都意味着要重写一半的测试代码，让你对重构本身心生恐惧，技术债越积越多。</li>
<li><strong>“发布”前的祈祷者：</strong> 当项目越来越大，你敢在没有一轮紧张的手动回归测试的情况下，自信地点击“发布”按钮吗？go test ./&#8230; 的漫长等待是否已经让你无法忍受？</li>
</ol>
<p>如果以上问题让你感同身受，那说明我们的测试体系，可能还停留在<strong>“演员在镜子前练习自己台词”</strong>的阶段。它能保证你自己的“台词”（单个函数）没问题，却无法保证你在“舞台”上（真实环境）与其他“演员”（数据库、缓存、API）的配合不出错。</p>
<p>而线上事故，往往就出在这些<strong>“接缝”</strong>之处。</p>
<h2>真正的信心，源自体系化的“测试之道”</h2>
<p>那么，如何构建一个能真正守护我们安稳度过每个周末的测试体系呢？答案不在于写更多的单元测试，而在于建立一个科学、分层、覆盖从已知到未知的自动化测试系统。</p>
<p>这不仅仅是一门教你写测试的课程。这是一门<strong>为你注入“持续交付信心”的工程实践课</strong>。</p>
<p>我将以一个贯穿始终的“短链接”实战项目为例，带你走过一条完整的进阶之路——<strong>从构建坚实的“测试金字塔”，到掌握前沿的“高级实践”</strong>。</p>
<p><img src="https://tonybai.com/wp-content/uploads/2025/go-testing-journey-2.png" alt="" /></p>
<p><strong>在这门专栏里，你将获得什么？</strong></p>
<ol>
<li><strong>一套完整的 Go 测试“作战地图”:</strong> 我们将自底向上，系统性地构建<strong>单元测试、集成测试、契约测试</strong>和<strong>端到端测试</strong>，让你清晰地知道在何处写何种测试。</li>
<li><strong>“驯服”外部依赖的终极武器:</strong> 我将手把手带你使用 <strong>Testcontainers</strong>，在测试代码中“一键”拉起真实的数据库和 Redis，彻底告别脆弱的 Mock 和不稳定的共享测试环境。</li>
<li><strong>一个装满“黑魔法”的高级工具箱:</strong> 我们不会止步于基础。你还将学到：
<ul>
<li>如何用<strong>覆盖率 (Coverage)</strong> 分析工具为你的测试“查漏补缺”。</li>
<li>如何用<strong>模糊测试 (Fuzzing)</strong> 去探索人类思维难以触及的“未知”边界。</li>
<li>如何用<strong>黄金文件 (Golden Files)</strong> 优雅地解决对复杂输出的断言难题。</li>
</ul>
</li>
<li><strong>一种全新的“可靠性”思维:</strong> 我们将初步探索<strong>混沌工程 (Chaos Engineering)</strong>，学习如何在测试中有控制地注入网络延迟、中断等故障，将你的测试思维从“验证功能”提升到“考验韧性”。</li>
<li><strong>最终目标：</strong> 让你拥有在任何时候都敢于<strong>自信重构、放心发布</strong>的工程能力。</li>
</ol>
<h2>专栏学习路径一览</h2>
<p>为了让你对这次学习之旅有更清晰的预期，这里是我们将要共同探索的“新大陆地图”：</p>
<ul>
<li><strong>模块一：测试金字塔之基 (地基篇)</strong>
<ul>
<li><strong>第 1-3 讲:</strong> 深入<strong>单元测试</strong>，掌握表驱动、Fake Object、httptest 等核心技巧，为 service 和 handler 层构建坚固的“零件”质量保证。</li>
</ul>
</li>
<li><strong>模块二：测试金字塔之腰 (集成篇)</strong>
<ul>
<li><strong>第 4-6 讲:</strong> 掌握用<strong>构建约束</strong>隔离测试，并深入<strong>集成测试</strong>的核心。我们将用 <strong>Testcontainers</strong> 自动化编排 PostgreSQL 和 Redis，验证真实的服务间协作。</li>
</ul>
</li>
<li><strong>模块三：测试金字塔之顶 (验收篇)</strong>
<ul>
<li><strong>第 7-8 讲:</strong> 探索微服务时代的<strong>契约测试</strong>，并最终站在用户视角，用 docker-compose 搭建完整环境，进行<strong>端到端 (E2E) 测试</strong>的“终极验收”。</li>
</ul>
</li>
<li><strong>模块四：高级实践与可靠性工程 (进阶篇)</strong>
<ul>
<li><strong>第 9 讲 (高能预警!):</strong> Go 测试的“黑魔法”合集！一次性解锁<strong>覆盖率分析、Fuzzing 和 Golden Files</strong> 三大神器。</li>
<li><strong>第 10 讲 (思想升华!):</strong> 拥抱“混乱”！学习<strong>混沌工程</strong>思想，并用 toxiproxy 在测试中主动注入网络故障，考验我们系统的韧性。</li>
</ul>
</li>
</ul>
<p>我们将最大化地利用 Go 原生工具链，让你看到 Go 设计的简洁与强大。每一讲都包含可运行的示例代码，保证你跟得上、学得会。</p>
<h2>与我一起，开启你的测试进阶之旅</h2>
<p>测试，是现代软件工程的基石，也是对未来那个需要维护你代码的自己，最好的投资。</p>
<p>如果你：</p>
<ul>
<li>对自己的测试代码缺乏信心，时常担心上线后出问题。</li>
<li>希望建立系统化的测试思维，向资深工程师或架构师迈进。</li>
<li>渴望掌握 Fuzzing、混沌工程等前沿测试技术，拓宽自己的技术视野。</li>
</ul>
<p>那么，这门 <strong>《Go 测试之道：从测试金字塔到高级实践》</strong> 就是为你量身打造的。</p>
<p>点击【<a href="https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MzIyNzM0MDk0Mg==&amp;action=getalbum&amp;album_id=4256541133263962115#wechat_redirect">这里</a>】或扫描下方二维码订阅该微专栏，让我们一起，告别提心吊胆的上线，迎接自信重构的未来！</p>
<p><img src="https://tonybai.com/wp-content/uploads/2025/go-testing-journey-qr.png" alt="" /></p>
<p>老规矩，你还可以加入我的知识星球，该微专栏已经在星球免费发布，你也可以与我和其他同学一起讨论测试中的疑难杂症，共同进步。</p>
<hr />
<p>你的Go技能，是否也卡在了“熟练”到“精通”的瓶颈期？</p>
<ul>
<li>想写出更地道、更健壮的Go代码，却总在细节上踩坑？</li>
<li>渴望提升软件设计能力，驾驭复杂Go项目却缺乏章法？</li>
<li>想打造生产级的Go服务，却在工程化实践中屡屡受挫？</li>
</ul>
<p>继《<a href="http://gk.link/a/10AVZ">Go语言第一课</a>》后，我的《<a href="http://gk.link/a/12yGY">Go语言进阶课</a>》终于在极客时间与大家见面了！</p>
<p>我的全新极客时间专栏 《<a href="http://gk.link/a/12yGY">Tony Bai·Go语言进阶课</a>》就是为这样的你量身打造！30+讲硬核内容，带你夯实语法认知，提升设计思维，锻造工程实践能力，更有实战项目串讲。</p>
<p>目标只有一个：助你完成从“Go熟练工”到“Go专家”的蜕变！ 现在就加入，让你的Go技能再上一个新台阶！</p>
<p><img src="https://tonybai.com/wp-content/uploads/course-card/iamtonybai-banner-2.gif" alt="" /></p>
<hr />
<p><strong>想系统学习Go，构建扎实的知识体系？</strong></p>
<p>我的新书《<a href="https://book.douban.com/subject/37499496/">Go语言第一课</a>》是你的首选。源自2.4万人好评的极客时间专栏，内容全面升级，同步至Go 1.24。首发期有专属五折优惠，不到40元即可入手，扫码即可拥有这本300页的Go语言入门宝典，即刻开启你的Go语言高效学习之旅！</p>
<p><img src="https://tonybai.com/wp-content/uploads/2025/go-primer-published-4.png" alt="" /></p>
<hr />
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求，请扫描下方公众号二维码，与我私信联系。</p>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p style='text-align:left'>&copy; 2025, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2025/11/17/go-testing-journey/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>Go项目设计的“七宗罪”？警惕那些流行的“反模式”</title>
		<link>https://tonybai.com/2025/04/21/go-project-design-antipatterns/</link>
		<comments>https://tonybai.com/2025/04/21/go-project-design-antipatterns/#comments</comments>
		<pubDate>Sun, 20 Apr 2025 23:20:07 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[anti-pattern]]></category>
		<category><![CDATA[Bug]]></category>
		<category><![CDATA[C]]></category>
		<category><![CDATA[cmd]]></category>
		<category><![CDATA[common]]></category>
		<category><![CDATA[DAG]]></category>
		<category><![CDATA[DesignPattern]]></category>
		<category><![CDATA[DRY]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[GoProverbs]]></category>
		<category><![CDATA[handler]]></category>
		<category><![CDATA[helpers]]></category>
		<category><![CDATA[import]]></category>
		<category><![CDATA[Interface]]></category>
		<category><![CDATA[internal]]></category>
		<category><![CDATA[Java]]></category>
		<category><![CDATA[linter]]></category>
		<category><![CDATA[Mock]]></category>
		<category><![CDATA[model]]></category>
		<category><![CDATA[Namespace]]></category>
		<category><![CDATA[Package]]></category>
		<category><![CDATA[pkg]]></category>
		<category><![CDATA[Service]]></category>
		<category><![CDATA[shared]]></category>
		<category><![CDATA[Testing]]></category>
		<category><![CDATA[util]]></category>
		<category><![CDATA[utils]]></category>
		<category><![CDATA[依赖包]]></category>
		<category><![CDATA[函数]]></category>
		<category><![CDATA[包]]></category>
		<category><![CDATA[反模式]]></category>
		<category><![CDATA[循环导入]]></category>
		<category><![CDATA[抽象]]></category>
		<category><![CDATA[接口]]></category>
		<category><![CDATA[接口隔离]]></category>
		<category><![CDATA[最佳实践]]></category>
		<category><![CDATA[标准]]></category>
		<category><![CDATA[测试]]></category>
		<category><![CDATA[简洁]]></category>
		<category><![CDATA[耦合]]></category>
		<category><![CDATA[表驱动测试]]></category>
		<category><![CDATA[规范]]></category>
		<category><![CDATA[设计模式]]></category>
		<category><![CDATA[过度设计]]></category>
		<category><![CDATA[项目布局]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=4596</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2025/04/21/go-project-design-antipatterns 大家好，我是Tony Bai。 在软件开发这个行当里，“最佳实践”、“设计模式”、“标准规范”这些词汇总是自带光环。它们总是承诺会带来更好的代码质量、可维护性和扩展性。然而，当这些“圣经”般的原则被生搬硬套到Go语言的语境下时，有时非但不能带来预期的好处，反而可能把我们引入“歧途”，滋生出一些看似“专业”实则有害的“反模式”。 最近我也拜读了几篇国外开发者关于Go项目布局和设计哲学的文章，结合我自己这些年的实践和观察，我愈发觉得，Go社区中确实存在一些需要警惕的、流行的设计“反模式”。这些“反模式”很多人都或多或少的使用过，包括曾经的我自己。 在这篇文章中，我就总结一下我眼中的Go项目设计“七宗罪”，希望能帮助大家在实践中保持清醒，做出更符合Go精神的决策。 第一宗罪：为了结构而结构——过度分层与分组 表现： 项目伊始，不假思索地创建pkg/、internal/、cmd/、util/、model/、handler/、service/ 等层层嵌套的目录，美其名曰“组织清晰”、“符合标准”。 危害： * 违背简洁： Go 的核心哲学是简洁。不必要的目录层级增加了认知负担和导航成本。 * 过早抽象/耦合： 在需求尚不明确时就划分 service、handler 等，可能导致错误的抽象边界和不必要的耦合。 * pkg/ 的迷思： pkg/ 是一个过时的、缺乏语义的约定，Go官方在Go 1.4时将Go项目中的pkg层次去掉了，Go官方的module布局指南中也使用了更多有意义的名字代替了pkg。 * internal/ 的滥用： 它是 Go 工具链的一个特性，用于保护内部实现不被外部导入。但如果你的项目根本不作为库被外部依赖，或者需要保护的代码很少，强制使用 internal/ 只会徒增复杂性。 * cmd/ 的误用： 除非你的仓库包含多个独立的可执行文件，否则将单一的main.go放入cmd/毫无必要。 解药： 保持扁平！从根目录开始，根据实际的功能或领域需要创建有意义的包。让结构随着项目的增长有机演化，而不是一开始就套用模板。 注：笔者当年也是pkg的“忠实粉丝”，新创建一个项目，无论规模大小，总喜欢先将pkg目录预创建出来。现在是时候根据项目的演进和规模的增长来判断是否需要”pkg”这个有点像“namespace”的目录了，即当你有多个希望公开的库时，是否用pkg/作为一个顶层分组，这个是要基于项目的实际情况进行判断的。 第二宗罪：无效的“美化运动”——无价值的重构与移动 表现： 为了让代码看起来“更干净”、“更符合某种设计模式”或“消除Linter警告”，在没有明确收益（修复 Bug、增加功能、提升性能、解决安全问题）的情况下，大规模地移动代码、修改变量名、调整文件结构。 危害： * 浪费时间精力： 投入大量时间做无意义的表面文章。 * 引入风险： 任何修改都有引入新 Bug [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/2025/go-project-design-antipatterns-1.jpg" alt="" /></p>
<p><a href="https://tonybai.com/2025/04/21/go-project-design-antipatterns">本文永久链接</a> &#8211; https://tonybai.com/2025/04/21/go-project-design-antipatterns</p>
<p>大家好，我是Tony Bai。</p>
<p>在软件开发这个行当里，“最佳实践”、“设计模式”、“标准规范”这些词汇总是自带光环。它们总是承诺会带来更好的代码质量、可维护性和扩展性。然而，当这些“圣经”般的原则被生搬硬套到Go语言的语境下时，有时非但不能带来预期的好处，反而可能把我们引入“歧途”，滋生出一些看似“专业”实则有害的“反模式”。</p>
<p>最近我也拜读了几篇国外开发者关于Go项目布局和设计哲学的文章，结合我自己这些年的实践和观察，我愈发觉得，Go社区中确实存在一些需要警惕的、流行的设计“反模式”。这些“反模式”很多人都或多或少的使用过，包括曾经的我自己。</p>
<p>在这篇文章中，我就总结一下我眼中的Go项目设计“七宗罪”，希望能帮助大家在实践中保持清醒，做出更符合Go精神的决策。</p>
<h2>第一宗罪：为了结构而结构——过度分层与分组</h2>
<p><img src="https://tonybai.com/wp-content/uploads/2025/go-project-design-antipatterns-2.jpg" alt="" /></p>
<p><strong>表现：</strong> 项目伊始，不假思索地创建pkg/、internal/、cmd/、util/、model/、handler/、service/ 等层层嵌套的目录，美其名曰“组织清晰”、“符合标准”。</p>
<p><strong>危害：</strong><br />
*   <strong>违背简洁：</strong> Go 的核心哲学是简洁。不必要的目录层级增加了认知负担和导航成本。<br />
*   <strong>过早抽象/耦合：</strong> 在需求尚不明确时就划分 service、handler 等，可能导致错误的抽象边界和不必要的耦合。<br />
*   <strong>pkg/ 的迷思：</strong> pkg/ 是一个过时的、缺乏语义的约定，Go官方在<a href="https://tonybai.com/2014/11/04/some-changes-in-go-1-4">Go 1.4</a>时将Go项目中的pkg层次去掉了，<a href="https://go.dev/doc/modules/layout">Go官方的module布局指南</a>中也使用了更多有意义的名字代替了pkg。<br />
*   <strong>internal/ 的滥用：</strong> 它是 Go 工具链的一个特性，用于保护内部实现不被外部导入。但如果你的项目根本不作为库被外部依赖，或者需要保护的代码很少，强制使用 internal/ 只会徒增复杂性。<br />
*   <strong>cmd/ 的误用：</strong> 除非你的仓库包含多个独立的可执行文件，否则将单一的main.go放入cmd/毫无必要。</p>
<p><strong>解药：</strong> 保持扁平！从根目录开始，根据<strong>实际的功能或领域</strong>需要创建<strong>有意义的包</strong>。让结构随着项目的增长<strong>有机演化</strong>，而不是一开始就套用模板。</p>
<blockquote>
<p>注：笔者当年也是pkg的“忠实粉丝”，新创建一个项目，无论规模大小，总喜欢先将pkg目录预创建出来。现在是时候根据项目的演进和规模的增长来判断是否需要”pkg”这个有点像“namespace”的目录了，即当你有多个希望公开的库时，是否用pkg/作为一个顶层分组，这个是要基于项目的实际情况进行判断的。</p>
</blockquote>
<h2>第二宗罪：无效的“美化运动”——无价值的重构与移动</h2>
<p><img src="https://tonybai.com/wp-content/uploads/2025/go-project-design-antipatterns-3.jpg" alt="" /></p>
<p><strong>表现：</strong> 为了让代码看起来“更干净”、“更符合某种设计模式”或“消除Linter警告”，在没有明确收益（修复 Bug、增加功能、提升性能、解决安全问题）的情况下，大规模地移动代码、修改变量名、调整文件结构。</p>
<p><strong>危害：</strong><br />
*   <strong>浪费时间精力：</strong> 投入大量时间做无意义的表面文章。<br />
*   <strong>引入风险：</strong> 任何修改都有引入新 Bug 的风险，没有价值的修改更是得不偿失。<br />
*   <strong>增加 Code Review 负担：</strong> 团队成员需要花费时间理解这些非功能性的变更。<br />
*   <strong>违背价值驱动：</strong> 软件工程的核心是交付价值，而不是追求代码的“艺术感”。</p>
<p><strong>解药：</strong> 坚持<strong>价值驱动</strong>的变更！在做任何结构或代码调整前，严格拷问自己：这个改动解决了什么<strong>真实的、当前存在</strong>的问题？它的收益是否能明确衡量并大于风险？</p>
<h2>第三宗罪：接口的“原罪”——过早、过度的抽象</h2>
<p><img src="https://tonybai.com/wp-content/uploads/2025/go-project-design-antipatterns-4.jpg" alt="" /></p>
<p><strong>表现：</strong><br />
*   在只有一个具体实现的情况下，就为其定义接口。<br />
*   定义庞大、臃肿的接口，包含过多方法。<br />
*   为了“可测试性”而无脑地给所有东西加上接口。</p>
<p><strong>危害：</strong><br />
*   <strong>不必要的抽象：</strong> 接口是为了解耦和多态。在不需要这些时引入接口，只会增加代码量和理解成本。<br />
*   <strong>弱化抽象能力：</strong> “接口越大，抽象越弱”（来自<a href="https://go-proverbs.github.io/">Go谚语</a>）。大接口难以实现和维护，它变得模糊，难以理解哪些方法是真正必要的，也失去了其作为“契约”的精准性。<br />
*   <strong>阻碍演化：</strong> 过早定义接口可能锁定不成熟的设计，后续修改成本更高。<br />
*   <strong>测试的借口：</strong> Go拥有强大的测试工具（如<a href="https://tonybai.com/2024/01/01/go-testing-by-example">表驱动测试</a>），很多时候并不需要接口来实现可测试性。为测试而引入的接口可能扭曲生产代码的设计。</p>
<p><strong>解药：</strong><br />
*   <strong>拥抱具体：</strong> 先写具体实现。<br />
*   <strong>发现接口，而非设计接口：</strong> 只有当你<strong>确实需要</strong>多种实现（包括<a href="https://tonybai.com/2023/04/20/provide-fake-object-for-external-collaborators">测试中的Mock</a>，但要谨慎对待），或者需要<strong>打破循环依赖</strong>时，才考虑提取接口。<br />
*   <strong>保持接口小巧、正交：</strong> 遵循接口隔离原则。</p>
<h2>第四宗罪：“大杂烩”的诱惑——utils/common/shared 黑洞</h2>
<p><img src="https://tonybai.com/wp-content/uploads/2025/go-project-design-antipatterns-5.jpg" alt="" /></p>
<p><strong>表现：</strong> 创建一个名为 utils、common、shared 或 helpers 的包，把各种看似“通用”的函数、类型塞进去。</p>
<p><strong>危害：</strong><br />
*   <strong>职责不清：</strong> 这些包缺乏明确的领域或功能归属，成为代码的“垃圾抽屉”。<br />
*   <strong>依赖洼地：</strong> 随着项目增长，这些包往往会依赖越来越多的其他包，同时也被越来越多的包依赖，极易引发循环依赖或成为构建瓶颈。<br />
*   <strong>降低内聚性：</strong> 本应属于特定领域的功能被剥离出来，破坏了原有包的内聚性。</p>
<p><strong>解药：</strong><br />
*   <strong>就近原则：</strong> 如果一个“工具函数”只被一个包使用，就把它放在那个包里（可以是私有的）。<br />
*   <strong>功能归类：</strong> 如果一个“工具函数”被多个包使用，思考它真正属于哪个<strong>功能领域</strong>，为其创建一个<strong>有意义的</strong>新包（例如 applog 而不是 logutil）。<br />
*   <strong>思考依赖方向：</strong> 真正通用的基础库（如自定义的 string 处理、时间处理）应该处于依赖关系图的底层，不应依赖上层业务逻辑。</p>
<blockquote>
<p>注：坦白说，其他几项“罪过”或许还只是部分开发者的“偶发行为”，但这“第四宗罪”——随手创建 utils 或 common 包——恐怕是我们绝大多数人都曾犯过，甚至习以为常的“通病”。笔者也是如此:)。</p>
</blockquote>
<h2>第五宗罪：对 DRY 的“迷信”——为了“不重复”而引入不当依赖</h2>
<p><img src="https://tonybai.com/wp-content/uploads/2025/go-project-design-antipatterns-6.jpg" alt="" /></p>
<p><strong>表现：</strong> 为了避免几行相似代码的重复，强行提取公共函数或类型，并为此引入新的包依赖，有时甚至导致复杂的依赖关系或循环依赖。</p>
<p><strong>危害：</strong><br />
*   <strong>错误的抽象：</strong> 有时看似重复的代码，在不同的上下文中可能有细微的差别或独立演化的需求。强行合并可能导致错误的抽象。<br />
*   <strong>不必要的耦合：</strong> 为了共享几行代码而引入整个包的依赖，增加了耦合度，可能比少量重复代码的维护成本更高。<br />
*   <strong>违背 Go 谚语：</strong> “A little copying is better than a little dependency.”（一点复制代码胜过一点点依赖）。Go 社区鼓励在权衡后接受适度的代码重复，以换取更低的耦合度和更高的独立性。</p>
<p><strong>解药：</strong><br />
*   <strong>批判性看待重复：</strong> 看到重复代码时，先思考它们是否真的是“同一件事”？它们的演化趋势是否一致？<br />
*   <strong>权衡成本：</strong> 引入依赖的成本（耦合、潜在冲突、维护负担）是否真的低于复制代码的成本？<br />
*   <strong>优先考虑简单：</strong> 在不确定时，保持简单，适度复制代码通常更安全。</p>
<blockquote>
<p>注：这种事儿，恐怕咱们自己或者团队里都遇到过不少：就为了用里面那一两个小函数，咔嚓一下，引入了一个庞大无比的依赖库。</p>
</blockquote>
<h2>第六宗罪：盲目崇拜与跟风——“伪标准”与“最佳实践”的陷阱</h2>
<p><img src="https://tonybai.com/wp-content/uploads/2025/go-project-design-antipatterns-7.jpg" alt="" /></p>
<p><strong>表现：</strong><br />
*   不加批判地复制某个“明星项目”或所谓的“Go 标准项目布局”（如已被社区诟病的<a href="https://github.com/golang-standards/project-layout">golang-standards/project-layout</a>）。<br />
*   将其他语言（如 Java, C#）的复杂模式生搬硬套到 Go 项目中。<br />
*   将任何 Linter 规则或所谓的“最佳实践”奉为圭臬，不考虑具体场景。</p>
<p><strong>危害：</strong><br />
*   <strong>脱离实际：</strong> 别人的“最佳实践”是基于他们的特定问题和上下文演化而来的，未必适合你的项目。<br />
*   <strong>扼杀思考：</strong> 放弃了基于自己项目需求进行独立思考和决策的机会。<br />
*   <strong>违背Go文化：</strong> Go 推崇实用主义和具体问题具体分析，而非僵化的教条。</p>
<p><strong>解药：</strong><br />
*   <strong>保持独立思考：</strong> 理解每个模式或实践要解决的<strong>原始问题</strong>是什么，它是否在你的项目中真实存在？<br />
*   <strong>以我为主，兼收并蓄：</strong> 学习和借鉴，但最终决策要基于你自己的项目需求、团队情况和对 Go 语言的理解。<br />
*   <strong>质疑“最佳”：</strong> 没有万能的“最佳实践”，只有在特定上下文中的“较好实践”。</p>
<blockquote>
<p>注：确实，很多Go初学者（甚至一些老手，包括我自己）都曾长期困惑甚至“抱怨”：官方为何不给出一个项目布局的指导呢？这个呼声持续多年后，Go官方终于在2023年发布了一份<a href="https://tonybai.com/2023/10/05/the-official-guide-of-organizing-go-project/">官方布局指南</a>。这份指南无疑是我们理解官方思路、开始设计Go项目布局的一个重要起点。</p>
</blockquote>
<h2>第七宗罪：与“引力”对抗——忽视 Go 的依赖约束</h2>
<p><img src="https://tonybai.com/wp-content/uploads/2025/go-project-design-antipatterns-8.jpg" alt="" /></p>
<p><strong>表现：</strong><br />
*   设计出隐含循环依赖的架构（例如，某些复杂的 ORM 模式，或者 Service 层与 Repository 层相互调用具体类型）。<br />
*   当遇到 import cycle not allowed 错误时，不从根本上调整结构，而是通过滥用接口、全局变量或 init() 函数等“技巧”来绕过编译错误。</p>
<p><strong>危害：</strong><br />
*   <strong>与语言对抗：</strong> Go禁止循环依赖是其核心设计之一，旨在强制形成清晰的、可管理的依赖关系图 (DAG)。试图绕过它，本质上是在与语言的设计哲学对抗。<br />
*   <strong>隐藏的复杂性：</strong> 用“技巧”解决循环依赖，只是将问题扫到地毯下，使得真实的依赖关系变得模糊不清，增加了维护难度。<br />
*   <strong>错失优化机会：</strong> 循环依赖往往是代码职责不清、耦合过度的信号。解决循环依赖的过程，本身就是一次优化架构、厘清职责的好机会。</p>
<p><strong>解药：</strong><br />
*   <strong>拥抱 DAG：</strong> 理解并尊重 Go 的依赖规则，将其视为架构设计的“向导”。<br />
*   <strong>分析依赖：</strong> 当出现循环依赖时，深入分析其根源，理解是哪个环节的职责划分或耦合出了问题。<br />
*   <strong>结构性解决：</strong> 优先使用移动代码、提取新包（向上或向下）等结构性方法来打破循环。接口解耦是可用手段，但不应是首选或唯一手段。</p>
<h2>小结：回归常识，拥抱简洁</h2>
<p>Go语言的设计哲学是务实和简洁。许多所谓的“最佳实践”和“复杂模式”，在Go的世界里可能水土不服。识别并避免上述这些“反模式”，需要我们：</p>
<ul>
<li><strong>保持批判性思维：</strong> 不盲从，不跟风，时刻追问“为什么”。</li>
<li><strong>坚持价值驱动：</strong> 让每一个设计决策都服务于解决真实问题。</li>
<li><strong>深刻理解Go：</strong> 尊重其核心约束（如无循环依赖），发挥其优势（如简洁性）。</li>
<li><strong>拥抱演化：</strong> 从简单开始，让架构随着需求的明确而有机生长。</li>
</ul>
<p>希望这篇“七宗罪”的总结能给大家带来一些警示和启发。<strong>你是否也曾在项目中遇到过这些“反模式”？你认为还有哪些Go设计中需要警惕的“坑”？欢迎在评论区分享你的看法和经验！</strong></p>
<p>也别忘了点个【赞】和【在看】，让更多Gopher看到这篇“反模式”的总结！</p>
<hr />
<p>避开这些设计“反模式”是迈向Go高手的关键一步。如果你渴望更深层次地理解Go语言精髓，与顶尖Gopher交流切磋，并紧跟Go+AI前沿动态…</p>
<p>那么，我的 <strong>「Go &amp; AI 精进营」知识星球</strong> 正是你需要的！在这里，你可以沉浸式学习【Go原理/进阶/避坑】等独家深度专栏，随时向我提问获<br />
得解析，并与高活跃社区成员碰撞思想火花。</p>
<p><strong>扫码加入，开启你的Go深度学习与精进之旅！</strong></p>
<p><img src="http://image.tonybai.com/img/tonybai/gopher-and-ai-tribe-zsxq-small-card.jpg" alt="img{512x368}" /></p>
<p style='text-align:left'>&copy; 2025, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2025/04/21/go-project-design-antipatterns/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>使用testify包辅助Go测试指南</title>
		<link>https://tonybai.com/2023/07/16/the-guide-of-go-testing-with-testify-package/</link>
		<comments>https://tonybai.com/2023/07/16/the-guide-of-go-testing-with-testify-package/#comments</comments>
		<pubDate>Sun, 16 Jul 2023 07:09:56 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[apache-arrow]]></category>
		<category><![CDATA[Arrow]]></category>
		<category><![CDATA[Assert]]></category>
		<category><![CDATA[Check]]></category>
		<category><![CDATA[CheckEqual]]></category>
		<category><![CDATA[DeepEqual]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[gotest]]></category>
		<category><![CDATA[Go语言第一课]]></category>
		<category><![CDATA[Go语言精进之路]]></category>
		<category><![CDATA[grank.io]]></category>
		<category><![CDATA[Mock]]></category>
		<category><![CDATA[mockery]]></category>
		<category><![CDATA[quick]]></category>
		<category><![CDATA[Reflect]]></category>
		<category><![CDATA[require]]></category>
		<category><![CDATA[Setup]]></category>
		<category><![CDATA[stake]]></category>
		<category><![CDATA[Stub]]></category>
		<category><![CDATA[suite]]></category>
		<category><![CDATA[teardown]]></category>
		<category><![CDATA[testcase]]></category>
		<category><![CDATA[testify]]></category>
		<category><![CDATA[Testing]]></category>
		<category><![CDATA[testsuite]]></category>
		<category><![CDATA[uber]]></category>
		<category><![CDATA[unit-test]]></category>
		<category><![CDATA[xUnit]]></category>
		<category><![CDATA[反射]]></category>
		<category><![CDATA[标准库]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=3942</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2023/07/16/the-guide-of-go-testing-with-testify-package 我虽然算不上Go标准库的“清教徒”，但在测试方面还多是基于标准库testing包以及go test框架的，除了需要mock的时候，基本上没有用过第三方的Go测试框架。我在《Go语言精进之路》一书中对Go测试组织的讲解也是基于Go testing包和go test框架的。 最近看Apache arrow代码，发现arrow的Go实现使用了testify项目组织和辅助测试： // compute/vector_hash_test.go func TestHashKernels(t *testing.T) { suite.Run(t, &#38;PrimitiveHashKernelSuite[int8]{}) suite.Run(t, &#38;PrimitiveHashKernelSuite[uint8]{}) suite.Run(t, &#38;PrimitiveHashKernelSuite[int16]{}) suite.Run(t, &#38;PrimitiveHashKernelSuite[uint16]{}) ... ... } type PrimitiveHashKernelSuite[T exec.IntTypes &#124; exec.UintTypes &#124; constraints.Float] struct { suite.Suite mem *memory.CheckedAllocator dt arrow.DataType } func (ps *PrimitiveHashKernelSuite[T]) SetupSuite() { ps.dt = exec.GetDataType[T]() } func (ps *PrimitiveHashKernelSuite[T]) SetupTest() { [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/the-guide-of-go-testing-with-testify-package-1.png" alt="" /></p>
<p><a href="https://tonybai.com/2023/07/16/the-guide-of-go-testing-with-testify-package">本文永久链接</a> &#8211; https://tonybai.com/2023/07/16/the-guide-of-go-testing-with-testify-package</p>
<p>我虽然算不上Go标准库的“清教徒”，但在测试方面还多是基于标准库testing包以及go test框架的，除了需要mock的时候，基本上没有用过第三方的Go测试框架。我在<a href="https://book.douban.com/subject/35720729/">《Go语言精进之路》</a>一书中对Go测试组织的讲解也是基于Go testing包和go test框架的。</p>
<p>最近看Apache arrow代码，发现arrow的Go实现使用了<a href="https://github.com/stretchr/testify">testify项目</a>组织和辅助测试：</p>
<pre><code>// compute/vector_hash_test.go

func TestHashKernels(t *testing.T) {
    suite.Run(t, &amp;PrimitiveHashKernelSuite[int8]{})
    suite.Run(t, &amp;PrimitiveHashKernelSuite[uint8]{})
    suite.Run(t, &amp;PrimitiveHashKernelSuite[int16]{})
    suite.Run(t, &amp;PrimitiveHashKernelSuite[uint16]{})
    ... ...
}

type PrimitiveHashKernelSuite[T exec.IntTypes | exec.UintTypes | constraints.Float] struct {
    suite.Suite

    mem *memory.CheckedAllocator
    dt  arrow.DataType
}

func (ps *PrimitiveHashKernelSuite[T]) SetupSuite() {
    ps.dt = exec.GetDataType[T]()
}

func (ps *PrimitiveHashKernelSuite[T]) SetupTest() {
    ps.mem = memory.NewCheckedAllocator(memory.DefaultAllocator)
}

func (ps *PrimitiveHashKernelSuite[T]) TearDownTest() {
    ps.mem.AssertSize(ps.T(), 0)
}

func (ps *PrimitiveHashKernelSuite[T]) TestUnique() {
    ... ...
}
</code></pre>
<p>同期，我在<a href="https://www.grank.io/">grank.io</a>上看到testify这个项目综合排名第一：</p>
<p><img src="https://tonybai.com/wp-content/uploads/the-guide-of-go-testing-with-testify-package-2.png" alt="" /></p>
<p>这说明testify项目在Go社区有着广泛的受众，testify为何能从众多go test第三方框架中脱颖而出？它有哪些与众不同的地方？如何更好地利用testify来辅助我们的Go测试？带着这些问题，我写下了这篇有关testify的文章，供大家参考。</p>
<h2>1. testify简介</h2>
<p>testify是一个用于Go语言的测试框架，与go testing包可以很好的融合在一起，并由go test驱动运行。testify提供的功能特性可以辅助Go开发人员更好地组织和更高效地编写测试用例，以保证软件的质量和可靠性。</p>
<p>testify能够得到社区的广泛接纳，与testify项目中<strong>包的简洁与独立的设计</strong>是密不可分的。下面是testify包的目录结构(去掉了用于生成代码的codegen和已经deprecated的http目录后)：</p>
<pre><code>$tree -F -L 1 testify |grep "/" |grep -v codegen|grep -v http
├── assert/
├── mock/
├── require/
└── suite/
</code></pre>
<blockquote>
<p>关于Go项目代码布局设计的系统讲解，可以参见我的<a href="http://gk.link/a/10AVZ">《Go语言第一课》专栏</a>的第5讲。</p>
</blockquote>
<p>包目录名直接反映了testify可以提供给Go开发者的功能特性：</p>
<ul>
<li>assert和require：断言工具包，辅助做测试结果判定；</li>
<li>mock：辅助编写mock test的工具包；</li>
<li>suite：提供了suite这一层的测试组织结构。</li>
</ul>
<p>下面我们就<strong>由浅入深</strong>的介绍testify的这几个重要的、可各自独立使用的包。我们先从<strong>使用门槛最低</strong>的assert包和require包开始，它们是一类的，这里放在一个章节中介绍。</p>
<h2>2. assert和require包</h2>
<p>我们在使用go testing包编写Go单元测试用例时，通常会用下面代码来判断目标函数执行结果是否符合预期：</p>
<pre><code>func TestFoo(t *testing.T) {
    v := Foo(5, 6) // Foo为被测目标函数
    if v != expected {
        t.Errorf("want %d, actual %d\n", expected, v)
    }
}
</code></pre>
<p>这样，如果测试用例要判断的结果很多，那么测试代码中就会存在很多if xx != yy以及Errorf/Fatalf之类的代码。有过一些其他语言编程经验的童鞋此时此刻肯定会说：<strong>是时候上assert了</strong>! 不过很遗憾，Go标准库包括其<a href="https://github.com/golang/exp">实验库(exp)</a>都没有提供带有assert断言机制的包。</p>
<blockquote>
<p>注：Go标准库testing/quick包中提供的Check和CheckEqual并非assert，它们用于测试两个函数参数在相同输入的情况下是否有相同的输出。如果不同，则输出导致输出不同的输入。此外，该quick包已经frozen，不再接受新Feature。</p>
</blockquote>
<p>testify为Go开发人员提供了assert包，为Go开发人员很大程度“解了近渴”。</p>
<p>assert包使用起来非常简单，下面是assert使用的常见场景示例：</p>
<pre><code>// assert/assert_test.go

func Add(a, b int) int {
    return a + b
}

func TestAssert(t *testing.T) {
    // Equal断言
    assert.Equal(t, 4, Add(1, 3), "The result should be 4")

    sl1 := []int{1, 2, 3}
    sl2 := []int{1, 2, 3}
    sl3 := []int{2, 3, 4}
    assert.Equal(t, sl1, sl2, "sl1 should equal to sl2 ")

    p1 := &amp;sl1
    p2 := &amp;sl2
    assert.Equal(t, p1, p2, "the content which p1 point to should equal to which p2 point to")

    err := errors.New("demo error")
    assert.EqualError(t, err, "demo error")

    // assert.Exactly(t, int32(123), int64(123)) // failed! both type and value must be same

    // 布尔断言
    assert.True(t, 1+1 == 2, "1+1 == 2 should be true")
    assert.Contains(t, "Hello World", "World")
    assert.Contains(t, []string{"Hello", "World"}, "World")
    assert.Contains(t, map[string]string{"Hello": "World"}, "Hello")
    assert.ElementsMatch(t, []int{1, 3, 2, 3}, []int{1, 3, 3, 2})

    // 反向断言
    assert.NotEqual(t, 4, Add(2, 3), "The result should not be 4")
    assert.NotEqual(t, sl1, sl3, "sl1 should not equal to sl3 ")
    assert.False(t, 1+1 == 3, "1+1 == 3 should be false")
    assert.Never(t, func() bool { return false }, time.Second, 10*time.Millisecond) //1秒之内condition参数都不为true，每10毫秒检查一次
    assert.NotContains(t, "Hello World", "Go")
}
</code></pre>
<p>我们看到assert包提供了Equal类、布尔类、反向类断言，assert包提供的断言函数有几十种，这里无法一一枚举，选择最适合你的测试场景的断言就好。</p>
<p>另外要注意的是，在Equal对切片作比较时，比较的是切片底层数组存储的内容是否相等；对指针作比较时，比较的是指针指向的内存块儿的数据是否相等，而不是指针本身的值是否相等。</p>
<blockquote>
<p>注：assert.Equal底层实现使用的是reflect.DeepEqual。</p>
</blockquote>
<p>我们看到assert包提供的断言函数第一个参数是testing.T的实例，如果一个测试用例里多次使用assert包的断言函数，我们每次都要传入testing.T的实例，比如下面示例：</p>
<pre><code>// assert/assert_test.go

func TestAdd1(t *testing.T) {
    result := Add(1, 3)
    assert.Equal(t, 4, result, "The result should be 4")
    result = Add(2, 2)
    assert.Equal(t, 4, result, "The result should be 4")
    result = Add(2, 3)
    assert.Equal(t, 5, result, "The result should be 5")
    result = Add(0, 3)
    assert.Equal(t, 3, result, "The result should be 3")
    result = Add(-1, 1)
    assert.Equal(t, 0, result, "The result should be 0")
}
</code></pre>
<p>这很verbose! assert包提供了替代方法，如下面示例：</p>
<pre><code>// assert/assert_test.go

func TestAdd2(t *testing.T) {
    assert := assert.New(t)

    result := Add(1, 3)
    assert.Equal(4, result, "The result should be 4")
    result = Add(2, 2)
    assert.Equal(4, result, "The result should be 4")
    result = Add(2, 3)
    assert.Equal(5, result, "The result should be 5")
    result = Add(0, 3)
    assert.Equal(3, result, "The result should be 3")
    result = Add(-1, 1)
    assert.Equal(0, result, "The result should be 0")
}
</code></pre>
<blockquote>
<p>注：我们当然可以使用表驱动测试的方法将上述示例做进一步优化。</p>
</blockquote>
<p>require包可以理解为assert包的“姊妹包”，require包实现了assert包提供的所有导出的断言函数，因此我们将上述示例中的assert改为require后，代码可以正常编译和运行(见require/require_test.go)。</p>
<p>那么require包与assert包有什么不同呢？我们来简单看一下。</p>
<p>使用assert包的断言时，如果某一个断言失败，该失败不会影响到后续测试代码的执行，或者说后续测试代码会继续执行，比如我们故意将TestAssert中的一些断言条件改为失败：</p>
<pre><code>// assert/assert_test.go

    assert.True(t, 1+1 == 3, "1+1 == 2 should be true")
    assert.Contains(t, "Hello World", "World1")
</code></pre>
<p>再运行assert_test.go中的测试，我们会看到下面结果：</p>
<pre><code>$go test
--- FAIL: TestAssert (1.00s)
    assert_test.go:34:
            Error Trace:
            Error:          Should be true
            Test:           TestAssert
            Messages:       1+1 == 2 should be true
    assert_test.go:35:
            Error Trace:
            Error:          "Hello World" does not contain "World1"
            Test:           TestAssert
FAIL
exit status 1
FAIL    demo    1.016s
</code></pre>
<p>我们看到：两个失败的测试断言都输出了！</p>
<p>我们再换到require/require_test.go下做同样的修改，并执行go test，我们得到如下结果：</p>
<pre><code>$go test require_test.go
--- FAIL: TestRequire (0.00s)
    require_test.go:34:
            Error Trace:
            Error:          Should be true
            Test:           TestRequire
            Messages:       1+1 == 2 should be true
FAIL
FAIL    command-line-arguments  0.012s
FAIL
</code></pre>
<p>我们看到当执行完第一条失败的断言后，测试便结束了！</p>
<p>这就是assert包和require包的区别！这有些类似于Errorf和Fatalf的区别！require包中断言函数一旦执行失败便会导致测试退出，后续的测试代码将无法继续执行。</p>
<p>另外require包还有一个“特点”，那就是它的主体代码(require.go和require_forward.go)都是自动生成的：</p>
<pre><code>// github.com/stretchr/testify/require/reqire.go
/*
  CODE GENERATED AUTOMATICALLY WITH github.com/stretchr/testify/_codegen
* THIS FILE MUST NOT BE EDITED BY HAND
 */
</code></pre>
<p>testify的代码生成采用了基于模板的方法，具体的自动生成原理可以参考[《A case for Go code generation: testify》] (https://levelup.gitconnected.com/a-case-for-go-code-generation-testify-73a4b0d46cb1)这篇文章。</p>
<h2>3. suite包</h2>
<p>Go testing包没有引入testsuite(测试套件)或testcase(测试用例)的概念，只有Test和<a href="https://tonybai.com/2023/03/15/an-intro-of-go-subtest/">SubTest</a>。对于熟悉xUnit那套测试组织方式的开发者来说，这种缺失很“别扭”！要么自己基于testing包来构建这种结构，要么使用第三方包的实现。</p>
<p><img src="https://tonybai.com/wp-content/uploads/the-guide-of-go-testing-with-testify-package-3.jpg" alt="" /><br />
<center>该图来自网络</center></p>
<p>testify的suite包为我们提供了一种基于suite/case结构组织测试代码的方式。下面是一个可以对testify suite定义的suite结构进行全面解析的示例(改编自testify suite包文档中的ExampleTestSuite示例)：</p>
<pre><code>// suite/suite_test.go

package main

import (
    "fmt"
    "testing"

    "github.com/stretchr/testify/suite"
)

type ExampleSuite struct {
    suite.Suite
    indent int
}

func (suite *ExampleSuite) indents() (result string) {
    for i := 0; i &lt; suite.indent; i++ {
        result += "----"
    }
    return
}

func (suite *ExampleSuite) SetupSuite() {
    fmt.Println("Suite setup")
}

func (suite *ExampleSuite) TearDownSuite() {
    fmt.Println("Suite teardown")
}

func (suite *ExampleSuite) SetupTest() {
    suite.indent++
    fmt.Println(suite.indents(), "Test setup")
}

func (suite *ExampleSuite) TearDownTest() {
    fmt.Println(suite.indents(), "Test teardown")
    suite.indent--
}

func (suite *ExampleSuite) BeforeTest(suiteName, testName string) {
    suite.indent++
    fmt.Printf("%sBefore %s.%s\n", suite.indents(), suiteName, testName)
}

func (suite *ExampleSuite) AfterTest(suiteName, testName string) {
    fmt.Printf("%sAfter %s.%s\n", suite.indents(), suiteName, testName)
    suite.indent--
}

func (suite *ExampleSuite) SetupSubTest() {
    suite.indent++
    fmt.Println(suite.indents(), "SubTest setup")
}

func (suite *ExampleSuite) TearDownSubTest() {
    fmt.Println(suite.indents(), "SubTest teardown")
    suite.indent--
}

func (suite *ExampleSuite) TestCase1() {
    suite.indent++
    defer func() {
        fmt.Println(suite.indents(), "End TestCase1")
        suite.indent--
    }()

    fmt.Println(suite.indents(), "Begin TestCase1")

    suite.Run("case1-subtest1", func() {
        suite.indent++
        fmt.Println(suite.indents(), "Begin TestCase1.Subtest1")
        fmt.Println(suite.indents(), "End TestCase1.Subtest1")
        suite.indent--
    })
    suite.Run("case1-subtest2", func() {
        suite.indent++
        fmt.Println(suite.indents(), "Begin TestCase1.Subtest2")
        fmt.Println(suite.indents(), "End TestCase1.Subtest2")
        suite.indent--
    })
}

func (suite *ExampleSuite) TestCase2() {
    suite.indent++
    defer func() {
        fmt.Println(suite.indents(), "End TestCase2")
        suite.indent--
    }()
    fmt.Println(suite.indents(), "Begin TestCase2")

    suite.Run("case2-subtest1", func() {
        suite.indent++
        fmt.Println(suite.indents(), "Begin TestCase2.Subtest1")
        fmt.Println(suite.indents(), "End TestCase2.Subtest1")
        suite.indent--
    })
}

func TestExampleSuite(t *testing.T) {
    suite.Run(t, new(ExampleSuite))
}
</code></pre>
<p>要知道testify.suite包定义的测试结构是什么样的，我们运行一下上述代码即可：</p>
<pre><code>$go test
Suite setup
---- Test setup
--------Before ExampleSuite.TestCase1
------------ Begin TestCase1
---------------- SubTest setup
-------------------- Begin TestCase1.Subtest1
-------------------- End TestCase1.Subtest1
---------------- SubTest teardown
---------------- SubTest setup
-------------------- Begin TestCase1.Subtest2
-------------------- End TestCase1.Subtest2
---------------- SubTest teardown
------------ End TestCase1
--------After ExampleSuite.TestCase1
---- Test teardown
---- Test setup
--------Before ExampleSuite.TestCase2
------------ Begin TestCase2
---------------- SubTest setup
-------------------- Begin TestCase2.Subtest1
-------------------- End TestCase2.Subtest1
---------------- SubTest teardown
------------ End TestCase2
--------After ExampleSuite.TestCase2
---- Test teardown
Suite teardown
</code></pre>
<p>信息量很大，我们慢慢说！</p>
<p>利用testify建立测试套件，我们需要<strong>自行定义嵌入了suite.Suite的结构体类型</strong>，如上面示例中的ExampleSuite。</p>
<p>testify与go testing兼容，由go test驱动执行，因此我们需要在一个TestXXX函数中创建ExampleSuite的实例，调用suite包的Run函数，并将执行权交给suite包的这个Run函数，后续的执行逻辑就是suite包Run函数的执行逻辑。在上述代码中，我们只定义了一个TestXXX，并使用suite.Run函数执行了ExampleSuite中的所有测试用例。</p>
<p>suite.Run函数的执行逻辑大致是：通过<a href="https://tonybai.com/2023/06/04/reflection-programming-guide-in-go">反射机制</a>得到了&#42;ExampleSuite类型的方法集合，并执行方法集合中名字以Test为前缀的所有方法。testify将用户自定义的XXXSuite类型中的每个以Test为前缀的方法当作是一个TestCase。</p>
<p>除了Suite和TestCase的概念外，testify.suite包还“预埋”了很多回调点，包括suite的Setup、TearDown；test case的Setup和TearDown、testcase的before和after；subtest的Setup和TearDown，这些回调点也由suite.Run函数来执行，回调点的执行顺序可以通过上面示例的执行结果看到。</p>
<blockquote>
<p>注意：subtest要通过XXXSuite的Run方法执行，而不要通过标准库testing.T的Run方法执行。</p>
</blockquote>
<p>我们知道：go test工具可以通过-run命令行参数来选择要执行的TestXXX函数，考虑到testify使用TestXXX函数拉起测试套件(XXXSuite)，因此从testify视角来看，通过go test -run可以选择执行哪个XXXSuite，前提是一个TestXXX中仅初始化和运行一种XXXSuite的所有测试用例。</p>
<p>如果要选择XXXSuite的方法(即testify眼中的测试用例)，我们不能用-run了，需要使用testify新增的-m命令行选项，下面是一个仅执行带有Case2关键字测试用例的示例：</p>
<pre><code>$go test -testify.m Case2
Suite setup
---- Test setup
--------Before ExampleSuite.TestCase2
------------ Begin TestCase2
---------------- SubTest setup
-------------------- Begin TestCase2.Subtest1
-------------------- End TestCase2.Subtest1
---------------- SubTest teardown
------------ End TestCase2
--------After ExampleSuite.TestCase2
---- Test teardown
Suite teardown
PASS
ok      demo    0.014s
</code></pre>
<p>综上，如果你使用testify的Suite/Case概念来组织你的测试代码，建议在每个TestXXX中仅初始化和运行一个XXXSuite，这样你可以通过-run选择特定的Suite执行。</p>
<h2>4. mock包</h2>
<p>最后我们来看看testify为辅助Go开发人员编写测试代码而提供的一个高级特性：mock。</p>
<p>在之前的文章中，我提到过：<a href="https://tonybai.com/2023/04/20/provide-fake-object-for-external-collaborators/">尽量使用fake object，而不是mock object</a>。mock这种测试替身有其难于理解、使用场合局限以及给予开发人员信心不足等弊端。</p>
<blockquote>
<p>注：近期原Go官方维护的<a href="https://github.com/golang/mock">golang/mock</a>也将维护权迁移给了uber，迁移后的新的mock库为<a href="https://github.com/uber/mock">go.uber.org/mock</a>。我在<a href="https://book.douban.com/subject/35720729/">《Go语言精进之路 vol2》</a>一书中对golang/mock做过详细的使用介绍，有兴趣的朋友可以去读一读。</p>
</blockquote>
<p>但“存在即合理”，显然mock也有它的用武空间，在社区也有它的拥趸，既然testify提供了mock包，这里就简单介绍一下它的基本使用方法。</p>
<p>我们用一个经典repo service的例子来演示如何使用testify mock，如下面代码示例：</p>
<pre><code>// mock/mock_test.go

type User struct {
    ID   int
    Name string
    Age  int
}

type UserRepository interface {
    CreateUser(user *User) (int, error)
    GetUserById(id int) (*User, error)
}

type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &amp;UserService{repo: repo}
}

func (s *UserService) CreateUser(name string, age int) (*User, error) {
    user := &amp;User{Name: name, Age: age}
    id, err := s.repo.CreateUser(user)
    if err != nil {
        return nil, err
    }
    user.ID = id
    return user, nil
}

func (s *UserService) GetUserById(id int) (*User, error) {
    return s.repo.GetUserById(id)
}
</code></pre>
<p>我们要提供一个UserService服务，通过该服务可以创建User，也可以通过ID获取User信息。服务的背后是一个UserRepository，你可以用任何方法实现UserRepository，为此我们将其抽象为一个接口UserRepository。UserService要依赖UserRepository才能让它的两个方法CreateUser和GetUserById正常工作。现在我们要测试UserService的这两个方法，但我们手里没有现成的UserRepository实现可用，我们也没有UserRepository的fake object。</p>
<p>这时我们仅能用mock。下面是使用testify mock给出的UserRepository接口的实现UserRepositoryMock：</p>
<pre><code>// mock/mock_test.go

type UserRepositoryMock struct {
    mock.Mock
}

func (m *UserRepositoryMock) CreateUser(user *User) (int, error) {
    args := m.Called(user)
    return args.Int(0), args.Error(1)
}

func (m *UserRepositoryMock) GetUserById(id int) (*User, error) {
    args := m.Called(id)
    return args.Get(0).(*User), args.Error(1)
}
</code></pre>
<p>我们基于mock.Mock创建一个新结构体类型UserRepositoryMock，这就是我们要创建的模拟UserRepository。我们实现了它的两个方法，与正常方法实现不同的是，在方法中我们使用的是mock.Mock提供的方法Called以及它的返回值来满足CreateUser和GetUserById两个方法的参数与返回值要求。</p>
<p>UserRepositoryMock这两个方法的实现是比较“模式化”的，其中调用的Called接收了外部方法的所有参数，然后通过Called的返回值args来构造满足外部方法的返回值。返回值构造的书写格式如下：</p>
<pre><code>args.&lt;ReturnValueType&gt;(&lt;index&gt;) // 其中index从0开始
</code></pre>
<p>以CreateUser为例，它有两个返回值int和error，那按照上面的书写格式，我们的返回值就应该为：args.int(0)和args.Error(1)。</p>
<p>对于复杂结构的返回值类型T，可使用断言方式，书写格式变为：</p>
<pre><code>args.Get(index).(T)
</code></pre>
<p>再以构造GetUserById的返回值&#42;User和error为例，我们按照复杂返回值构造的书写格式来编写，返回值就应该为args.Get(0).(*User)和args.Error(1)。</p>
<p>有了Mock后的UserRepository，我们就可以来编写UserService的方法的测试用例了：</p>
<pre><code>// mock/mock_test.go

func TestUserService_CreateUser(t *testing.T) {
    repo := new(UserRepositoryMock)
    service := NewUserService(repo)

    user := &amp;User{Name: "Alice", Age: 30}
    repo.On("CreateUser", user).Return(1, nil)

    createdUser, err := service.CreateUser(user.Name, user.Age)

    assert.NoError(t, err)
    assert.Equal(t, 1, createdUser.ID)
    assert.Equal(t, "Alice", createdUser.Name)
    assert.Equal(t, 30, createdUser.Age)

    repo.AssertExpectations(t)
}

func TestUserService_GetUserById(t *testing.T) {
    repo := new(UserRepositoryMock)
    service := NewUserService(repo)

    user := &amp;User{ID: 1, Name: "Alice", Age: 30}
    repo.On("GetUserById", 1).Return(user, nil)

    foundUser, err := service.GetUserById(1)

    assert.NoError(t, err)
    assert.Equal(t, 1, foundUser.ID)
    assert.Equal(t, "Alice", foundUser.Name)
    assert.Equal(t, 30, foundUser.Age)

    repo.AssertExpectations(t)
}
</code></pre>
<p>这两个TestXXX函数的编写模式也十分相近，以TestUserService_GetUserById为例，它先创建了UserRepositoryMock和UserService的实例，然后利用UserRepositoryMock来设置即将被调用的GetUserById方法的输入参数与返回值：</p>
<pre><code>user := &amp;User{ID: 1, Name: "Alice", Age: 30}
repo.On("GetUserById", 1).Return(user, nil)
</code></pre>
<p>这样当GetUserById在service.GetUserById方法中被调用时，它返回的就是上面设置的user地址值和nil。</p>
<p>之后，我们像常规测试用例那样，用assert包对返回的值与预期值做断言即可。</p>
<h2>5. 小结</h2>
<p>在本文中，我们讲解了testify这个第三方辅助测试包的结构，并针对其中的assert/require、suite和mock这几个相对独立的Go包的用法做了重点说明。</p>
<p>assert/require包是功能十分全面的测试断言包，即便你不使用suite、mock，你也可以单独使用assert/require包来减少你的测试代码中if != xxx的书写行数。</p>
<p>suite包则为我们提供了一个类xUnit的Suite/Case的测试代码组织形式的实现方案，并且这种方案与go testing包兼容，由go test驱动。</p>
<p>虽然我不建议用mock，但testify mock也实现了mock机制的基本功能。并且文中没有提及的是，结合<a href="https://vektra.github.io/mockery/latest/">mockery</a>工具和testify mock，我们可以针对接口为被测目标自动生成testify的mock部分代码，这会大大提交mock test的编写效率。</p>
<p>综上来看，testify这个项目的确非常有用，可以很好的辅助Go开发者高效的编写和组织测试用例。目前testify正在策划<a href="https://docs.google.com/forms/d/e/1FAIpQLScQweSh4N4QqK3JESHTNyHjx0-lMApCK1--GvbXlB3dKyydeg/">dev v2版本</a> ，相信不久将来落地的v2版本能给Go开发者带来更多的帮助。</p>
<p>本文涉及到的源码可以在<a href="https://github.com/bigwhite/experiments/tree/master/testify-examples">这里</a>下载。</p>
<hr />
<p><a href="https://wx.zsxq.com/dweb2/index/group/51284458844544">“Gopher部落”知识星球</a>旨在打造一个精品Go学习和进阶社群！高品质首发Go技术文章，“三天”首发阅读权，每年两期Go语言发展现状分析，每天提前1小时阅读到新鲜的Gopher日报，网课、技术专栏、图书内容前瞻，六小时内必答保证等满足你关于Go语言生态的所有需求！2023年，Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码，关注代码质量并深入理解Go核心技术，并继续加强与星友的互动。欢迎大家加入！</p>
<p><img src="http://image.tonybai.com/img/tonybai/gopher-tribe-zsxq-small-card.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/go-programming-from-beginner-to-master-qr.png" alt="img{512x368}" /></p>
<p><img src="http://image.tonybai.com/img/tonybai/go-first-course-banner.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/imooc-go-column-pgo-with-qr.jpg" alt="img{512x368}" /></p>
<p>著名云主机服务厂商DigitalOcean发布最新的主机计划，入门级Droplet配置升级为：1 core CPU、1G内存、25G高速SSD，价格5$/月。有使用DigitalOcean需求的朋友，可以打开这个<a href="https://m.do.co/c/bff6eed92687">链接地址</a>：https://m.do.co/c/bff6eed92687 开启你的DO主机之路。</p>
<p>Gopher Daily(Gopher每日新闻)归档仓库 &#8211; https://github.com/bigwhite/gopherdaily</p>
<p>我的联系方式：</p>
<ul>
<li>微博(暂不可用)：https://weibo.com/bigwhite20xx</li>
<li>微博2：https://weibo.com/u/6484441286</li>
<li>博客：tonybai.com</li>
<li>github: https://github.com/bigwhite</li>
</ul>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。</p>
<p style='text-align:left'>&copy; 2023, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2023/07/16/the-guide-of-go-testing-with-testify-package/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>单测时尽量用fake object</title>
		<link>https://tonybai.com/2023/04/20/provide-fake-object-for-external-collaborators/</link>
		<comments>https://tonybai.com/2023/04/20/provide-fake-object-for-external-collaborators/#comments</comments>
		<pubDate>Thu, 20 Apr 2023 14:13:11 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[.NET]]></category>
		<category><![CDATA[ChatGPT]]></category>
		<category><![CDATA[Database]]></category>
		<category><![CDATA[Dummy]]></category>
		<category><![CDATA[etcd]]></category>
		<category><![CDATA[FakeObject]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[gotest]]></category>
		<category><![CDATA[Go语言精进之路]]></category>
		<category><![CDATA[handler]]></category>
		<category><![CDATA[http]]></category>
		<category><![CDATA[httptest]]></category>
		<category><![CDATA[Mock]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Postgres]]></category>
		<category><![CDATA[redis]]></category>
		<category><![CDATA[sql]]></category>
		<category><![CDATA[Stub]]></category>
		<category><![CDATA[SUT]]></category>
		<category><![CDATA[testcontainers]]></category>
		<category><![CDATA[testcontainers-go]]></category>
		<category><![CDATA[TestDouble]]></category>
		<category><![CDATA[testify]]></category>
		<category><![CDATA[Testing]]></category>
		<category><![CDATA[unit-testing]]></category>
		<category><![CDATA[xUnit]]></category>
		<category><![CDATA[单元测试]]></category>
		<category><![CDATA[单测]]></category>
		<category><![CDATA[外部协作者]]></category>
		<category><![CDATA[框架]]></category>
		<category><![CDATA[测试替身]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=3860</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2023/04/20/provide-fake-object-for-external-collaborators 1. 单元测试的难点：外部协作者(external collaborators)的存在 单元测试是软件开发的一个重要部分，它有助于在开发周期的早期发现错误，帮助开发人员增加对生产代码正常工作的信心，同时也有助于改善代码设计。Go语言从诞生那天起就内置Testing框架(以及测试覆盖率计算工具)，基于该框架，Gopher们可以非常方便地为自己设计实现的package编写测试代码。 注：《Go语言精进之路》vol2中的第40条到第44条有关于Go包内、包外测试区别、测试代码组织、表驱动测试、管理外部测试数据等内容的系统地讲解，感兴趣的童鞋可以读读。 不过即便如此，在实际开发工作中，大家发现单元测试的覆盖率依旧很低，究其原因，排除那些对测试代码不作要求的组织，剩下的无非就是代码设计不佳，使得代码不易测；或是代码有外部协作者（比如数据库、redis、其他服务等）。代码不易测可以通过重构来改善，但如果代码有外部协作者，我们该如何对代码进行测试呢，这也是各种编程语言实施单元测试的一大共同难点。 为此，《xUnit Test Patterns : Refactoring Test Code》一书中提供了Test Double(测试替身)的概念专为解决此难题。那么什么是Test Double呢？我们接下来就来简单介绍一下Test Double的概念以及常见的种类。 2. 什么是Test Double？ 测试替身是在测试阶段用来替代被测系统依赖的真实组件的对象或程序(如下图)，以方便测试，这些真实组件或程序即是外部协作者(external collaborators)。这些外部协作者在测试环境下通常很难获取或与之交互。测试替身可以使开发人员或QA专业人员专注于新的代码而不是代码与环境集成。 测试替身是通用术语，指的是不同类型的替换对象或程序。目前xUnit Patterns至少定义了五种类型的Test Doubles： Test stubs Mock objects Test spies Fake objects Dummy objects 这其中最为常用的是Fake objects、stub和mock objects。下面逐一说说这三种test double： 2.1 fake object fake object最容易理解，它是被测系统SUT(System Under Test)依赖的外部协作者的“替身”，和真实的外部协作者相比，fake object外部行为表现与真实组件几乎是一致的，但更简单也更易于使用，实现更轻量，仅用于满足测试需求即可。 fake object也是Go testing中最为常用的一类fake object。以Go的标准库为例，我们在src/database/sql下面就看到了Go标准库为进行sql包测试而实现的一个database driver： // [...]]]></description>
			<content:encoded><![CDATA[<p><a href="https://tonybai.com/2023/04/20/provide-fake-object-for-external-collaborators">本文永久链接</a> &#8211; https://tonybai.com/2023/04/20/provide-fake-object-for-external-collaborators</p>
<p><img src="https://tonybai.com/wp-content/uploads/provide-fake-object-for-external-collaborators-1.png" alt="" /></p>
<h2>1. 单元测试的难点：外部协作者(external collaborators)的存在</h2>
<p>单元测试是软件开发的一个重要部分，它有助于在开发周期的早期发现错误，帮助开发人员增加对生产代码正常工作的信心，同时也有助于改善代码设计。<strong>Go语言从诞生那天起就内置Testing框架(以及测试覆盖率计算工具)</strong>，基于该框架，Gopher们可以非常方便地为自己设计实现的package编写测试代码。</p>
<blockquote>
<p>注：<a href="https://book.douban.com/subject/35720729/">《Go语言精进之路》vol2</a>中的第40条到第44条有关于Go包内、包外测试区别、测试代码组织、表驱动测试、管理外部测试数据等内容的系统地讲解，感兴趣的童鞋可以读读。</p>
</blockquote>
<p>不过即便如此，在实际开发工作中，大家发现单元测试的覆盖率依旧很低，究其原因，排除那些对测试代码不作要求的组织，剩下的无非就是代码设计不佳，使得代码不易测；或是代码有外部协作者（比如数据库、redis、其他服务等）。代码不易测可以通过重构来改善，但如果代码有外部协作者，我们该如何对代码进行测试呢，这也是<strong>各种编程语言实施单元测试的一大共同难点</strong>。</p>
<p>为此，<a href="https://book.douban.com/subject/1859393/">《xUnit Test Patterns : Refactoring Test Code》</a>一书中提供了<strong>Test Double(测试替身)</strong>的概念专为解决此难题。那么什么是Test Double呢？我们接下来就来简单介绍一下Test Double的概念以及常见的种类。</p>
<h2>2. 什么是Test Double？</h2>
<p>测试替身是在测试阶段用来替代被测系统依赖的真实组件的对象或程序(如下图)，以方便测试，这些真实组件或程序即是外部协作者(external collaborators)。这些外部协作者在测试环境下通常很难获取或与之交互。测试替身可以使开发人员或QA专业人员专注于新的代码而不是代码与环境集成。</p>
<p><img src="https://tonybai.com/wp-content/uploads/provide-fake-object-for-external-collaborators-2.png" alt="" /></p>
<p>测试替身是通用术语，指的是不同类型的替换对象或程序。目前<a href="http://xunitpatterns.com/Test%20Double%20Patterns.html">xUnit Patterns</a>至少定义了五种类型的Test Doubles：</p>
<ul>
<li>Test stubs</li>
<li>Mock objects</li>
<li>Test spies</li>
<li>Fake objects</li>
<li>Dummy objects</li>
</ul>
<p>这其中最为常用的是Fake objects、stub和mock objects。下面逐一说说这三种test double：</p>
<h3>2.1 fake object</h3>
<p>fake object最容易理解，它是被测系统SUT(System Under Test)依赖的外部协作者的“替身”，和真实的外部协作者相比，fake object外部行为表现与真实组件几乎是一致的，但更简单也更易于使用，实现更轻量，仅用于满足测试需求即可。</p>
<p>fake object也是Go testing中最为常用的一类fake object。以Go的标准库为例，我们在src/database/sql下面就看到了Go标准库为进行sql包测试而实现的一个database driver：</p>
<pre><code>// $GOROOT/src/database/fakedb_test.go

var fdriver driver.Driver = &amp;fakeDriver{}

func init() {
    Register("test", fdriver)
}
</code></pre>
<p>我们知道一个真实的sql数据库的代码量可是数以百万计的，这里不可能实现一个生产级的真实SQL数据库，从fakedb_test.go源文件的注释我们也可以看到，这个fakeDriver仅仅是用于testing，它是一个实现了driver.Driver接口的、支持少数几个DDL(create)、DML(insert)和DQL(selet)的toy版的纯内存数据库：</p>
<pre><code>// fakeDriver is a fake database that implements Go's driver.Driver
// interface, just for testing.
//
// It speaks a query language that's semantically similar to but
// syntactically different and simpler than SQL.  The syntax is as
// follows:
//
//  WIPE
//  CREATE|&lt;tablename&gt;|&lt;col&gt;=&lt;type&gt;,&lt;col&gt;=&lt;type&gt;,...
//    where types are: "string", [u]int{8,16,32,64}, "bool"
//  INSERT|&lt;tablename&gt;|col=val,col2=val2,col3=?
//  SELECT|&lt;tablename&gt;|projectcol1,projectcol2|filtercol=?,filtercol2=?
//  SELECT|&lt;tablename&gt;|projectcol1,projectcol2|filtercol=?param1,filtercol2=?param2
</code></pre>
<p>与此类似的，Go标准库中还有net/dnsclient_unix_test.go中的fakeDNSServer等。此外，Go标准库中一些以mock做前缀命名的变量、类型等其实质上是fake object。</p>
<p>我们再来看第二种test double: stub。</p>
<h3>2.2 stub</h3>
<p>stub显然也是一个在测试阶段专用的、用来替代真实外部协作者与SUT进行交互的对象。与fake object稍有不同的是，stub是一个内置了预期值/响应值且可以在多个测试间复用的替身object。</p>
<p>stub可以理解为一种fake object的特例。</p>
<blockquote>
<p>注：fakeDriver在sql_test.go中的不同测试场景中时而是fake object，时而是stub(见sql_test.go中的newTestDBConnector函数)。</p>
</blockquote>
<p>Go标准库中的net/http/httptest就是一个提供创建stub的典型的测试辅助包，十分适合对http.Handler进行测试，这样我们无需真正启动一个http server。下面就是基于httptest的一个测试例子：</p>
<pre><code>// 被测对象 client.go

package main

import (
    "bytes"
    "net/http"
)

// Function that uses the client to make a request and parse the response
func GetResponse(client *http.Client, url string) (string, error) {
    req, err := http.NewRequest("GET", url, nil)
    if err != nil {
        return "", err
    }

    resp, err := client.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    buf := new(bytes.Buffer)
    _, err = buf.ReadFrom(resp.Body)
    if err != nil {
        return "", err
    }

    return buf.String(), nil
}

// 测试代码 client_test.go

package main

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestClient(t *testing.T) {
    // Create a new test server with a handler that returns a specific response
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"message": "Hello, world!"}`))
    }))
    defer server.Close()

    // Create a new client that uses the test server
    client := server.Client()

    // Call the function that uses the client
    message, err := GetResponse(client, server.URL)

    // Check that the response is correct
    expected := `{"message": "Hello, world!"}`
    if message != expected {
        t.Errorf("Expected response %q, but got %q", expected, message)
    }

    // Check that no errors were returned
    if err != nil {
        t.Errorf("Unexpected error: %v", err)
    }
}
</code></pre>
<p>在这个例子中，我们要测试一个名为GetResponse的函数，该函数通过client向url发送Get请求，并将收到的响应内容读取出来并返回。为了测试这个函数，我们需要“建立”一个与GetResponse进行协作的外部http server，这里我们使用的就是httptest包。我们通过httptest.NewServer建立这个server，该server预置了一个返回特定响应的HTTP handler。我们通过该server得到client和对应的url参数后，将其传给被测目标GetResponse，并将其返回的结果与预期作比较来完成这个测试。注意，我们在测试结束后使用defer server.Close()来关闭测试服务器，以确保该服务器不会在测试结束后继续运行。</p>
<p>httptest还常用来做http.Handler的测试，比如下面这个例子：</p>
<pre><code>// handler.go

package main

import (
    "bytes"
    "io"
    "net/http"
)

func AddHelloPrefix(w http.ResponseWriter, r *http.Request) {
    b, err := io.ReadAll(r.Body)
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    w.Write(bytes.Join([][]byte{[]byte("hello, "), b}, nil))
    w.WriteHeader(http.StatusOK)
}

// handler_test.go

package main

import (
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

func TestHandler(t *testing.T) {
    r := strings.NewReader("world!")
    req, err := http.NewRequest("GET", "/test", r)
    if err != nil {
        t.Fatal(err)
    }

    rr := httptest.NewRecorder()
    handler := http.HandlerFunc(AddHelloPrefix)
    handler.ServeHTTP(rr, req)

    if status := rr.Code; status != http.StatusOK {
        t.Errorf("handler returned wrong status code: got %v want %v",
            status, http.StatusOK)
    }

    expected := "hello, world!"
    if rr.Body.String() != expected {
        t.Errorf("handler returned unexpected body: got %v want %v",
            rr.Body.String(), expected)
    }
}
</code></pre>
<p>在这个例子中，我们创建一个新的http.Request对象，用于向/test路径发出GET请求。然后我们创建一个新的httptest.ResponseRecorder对象来捕获服务器的响应。 我们定义一个简单的HTTP Handler(被测函数): AddHelloPrefix，该Handler会在请求的内容之前加上”hello, “并返回200 OK状态代码作为响应体。之后，我们在handler上调用ServeHTTP方法，传入httptest.ResponseRecorder和http.Request对象，这会将请求“发送”到处理程序并捕获响应。最后，我们使用标准的Go测试包来检查响应是否具有预期的状态码和正文。</p>
<p>在这个例子中，我们利用net/http/httptest创建了一个测试服务器“替身”，并向其“发送”间接预置信息的请求以测试Go中的HTTP handler。这个过程中其实并没有任何网络通信，也没有http协议打包和解包的过程，我们也不关心http通信，那是Go net/http包的事情，我们只care我们的Handler是否能按逻辑运行。</p>
<p>fake object与stub的优缺点基本一样。多数情况下，<strong>大家也无需将这二者划分的很清晰</strong>。</p>
<h3>2.3 mock object</h3>
<p>和fake/stub一样，mock object也是一个测试替身。通过上面的例子我们看到fake建立困难(比如创建一个近2千行代码的fakeDriver)，但使用简单。而mock object则是一种建立简单，使用简单程度因被测目标与外部协作者交互复杂程度而异的test double，我们看一下下面这个例子：</p>
<pre><code>// db.go 被测目标

package main

// Define the `Database` interface
type Database interface {
    Save(data string) error
    Get(id int) (string, error)
}

// Example functions that use the `Database` interface
func saveData(db Database, data string) error {
    return db.Save(data)
}

func getData(db Database, id int) (string, error) {
    return db.Get(id)
}

// 测试代码

package main

import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

// Define a mock struct that implements the `Database` interface
type MockDatabase struct {
    mock.Mock
}

func (m *MockDatabase) Save(data string) error {
    args := m.Called(data)
    return args.Error(0)
}

func (m *MockDatabase) Get(id int) (string, error) {
    args := m.Called(id)
    return args.String(0), args.Error(1)
}

func TestSaveData(t *testing.T) {
    // Create a new mock database
    db := new(MockDatabase)

    // Expect the `Save` method to be called with "test data"
    db.On("Save", "test data").Return(nil)

    // Call the code that uses the database
    err := saveData(db, "test data")

    // Assert that the `Save` method was called with the correct argument
    db.AssertCalled(t, "Save", "test data")

    // Assert that no errors were returned
    assert.NoError(t, err)
}

func TestGetData(t *testing.T) {
    // Create a new mock database
    db := new(MockDatabase)

    // Expect the `Get` method to be called with ID 123 and return "test data"
    db.On("Get", 123).Return("test data", nil)

    // Call the code that uses the database
    data, err := getData(db, 123)

    // Assert that the `Get` method was called with the correct argument
    db.AssertCalled(t, "Get", 123)

    // Assert that the correct data was returned
    assert.Equal(t, "test data", data)

    // Assert that no errors were returned
    assert.NoError(t, err)
}
</code></pre>
<p>在这个例子中，被测目标是两个接受Database接口类型参数的函数：saveData和getData。显然在单元测试阶段，我们不能真正为这两个函数传入真实的Database实例去测试。</p>
<p>这里，我们没有使用fake object，而是定义了一个mock object：MockDatabase，该类型实现了Database接口。然后我们定义了两个测试函数，TestSaveData和TestGetData，它们分别使用MockDatabase实例来测试saveData和getData函数。</p>
<p>在每个测试函数中，我们对MockDatabase实例进行设置，包括期待特定参数的方法调用，然后调用使用该数据库的代码(即被测目标函数saveData和getData)。然后我们使用github.com/stretchr/testify中的assert包，对代码的预期行为进行断言。</p>
<blockquote>
<p>注：除了上述测试中使用的AssertCalled方法外，MockDatabase结构还提供了其他方法来断言方法被调用的次数、方法被调用的顺序等。请查看github.com/stretchr/testify/mock包的文档，了解更多信息。</p>
</blockquote>
<h2>3. Test Double有多种，选哪个呢？</h2>
<p>从mock object的例子来看，测试代码的核心就是mock object的构建与mock object的方法的参数和返回结果的设置，相较于fake object的简单直接，mock object在使用上较为难于理解。而且对Go语言来说，mock object要与接口类型联合使用，如果被测目标的参数是非接口类型，mock object便“无从下嘴”了。此外，mock object使用难易程度与被测目标与外部协作者的交互复杂度相关。像上面这个例子，建立mock object就比较简单。但对于一些复杂的函数，当存在多个外部协作者且与每个协作者都有多次交互的情况下，建立和设置mock object就将变得困难并更加难于理解。</p>
<p>mock object仅是满足了被测目标对依赖的外部协作者的调用需求，比如设置不同参数传入下的不同返回值，但mock object并未真实处理被测目标传入的参数，这会降低测试的可信度以及开发人员对代码正确性的信心。</p>
<p>此外，如果被测函数的输入输出未发生变化，但内部逻辑发生了变化，比如调用的外部协作者的方法参数、调用次数等，使用mock object的测试代码也需要一并更新维护。</p>
<p>而通过上面的fakeDriver、fakeDNSSever以及httptest应用的例子，我们看到：作为test double，fake object/stub有如下优点：</p>
<ul>
<li>我们与fake object的交互方式与与真实外部协作者交互的方式相同，这让其显得更简单，更容易使用，也降低了测试的复杂性；</li>
<li>fake objet的行为更像真正的协作者，可以给开发人员更多的信心；</li>
<li>当真实协作者更新时，我们不需要更新使用fake object时设置的expection和结果验证条件，因此，使用fake object时，重构代码往往比使用其他test double更容易。</li>
</ul>
<p>不过fake object也有自己的不足之处，比如：</p>
<ul>
<li>fake object的创建和维护可能很费时，就像上面的fakeDriver，源码有近2k行；</li>
<li>fake object可能无法提供与真实组件相同的功能覆盖水平，这与fake object的提供方式有关。</li>
<li>fake object的实现需要维护，每当真正的协作者更新时，都必须更新fake object。</li>
</ul>
<p>综上，测试的主要意义是保证SUT代码的正确性，让开发人员对自己编写的代码更有信心，从这个角度来看，我们<strong>在单测时应首选为外部协作者提供fake object以满足测试需要</strong>。</p>
<h3>4. fake object的实现和获取方法</h3>
<p>随着技术的进步，fake object的实现和获取日益容易。</p>
<p>我们可以借助类似ChatGPT/copilot的工具快速构建出一个fake object，即便是几百行代码的fake object的实现也很容易。</p>
<p>如果要更高的可信度和更高的功能覆盖水平，我们还可以借助docker来构建“真实版/无阉割版”的fake object。</p>
<p>借助github上开源的<a href="https://golang.testcontainers.org/">testcontainers-go</a>可以更为简便的构建出一个fake object，并且testcontainer提供了常见的外部协作者的封装实现，比如：MySQL、Redis、Postgres等。</p>
<p>以测试redis client为例，我们使用testcontainer建立如下测试代码：</p>
<pre><code>// redis_test.go

package main

import (
    "context"
    "fmt"
    "testing"

    "github.com/go-redis/redis/v8"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/wait"
)

func TestRedisClient(t *testing.T) {
    // Create a Redis container with a random port and wait for it to start
    req := testcontainers.ContainerRequest{
        Image:        "redis:latest",
        ExposedPorts: []string{"6379/tcp"},
        WaitingFor:   wait.ForLog("Ready to accept connections"),
    }
    ctx := context.Background()
    redisC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: req,
        Started:          true,
    })
    if err != nil {
        t.Fatalf("Failed to start Redis container: %v", err)
    }
    defer redisC.Terminate(ctx)

    // Get the Redis container's host and port
    redisHost, err := redisC.Host(ctx)
    if err != nil {
        t.Fatalf("Failed to get Redis container's host: %v", err)
    }
    redisPort, err := redisC.MappedPort(ctx, "6379/tcp")
    if err != nil {
        t.Fatalf("Failed to get Redis container's port: %v", err)
    }

    // Create a Redis client and perform some operations
    client := redis.NewClient(&amp;redis.Options{
        Addr: fmt.Sprintf("%s:%s", redisHost, redisPort.Port()),
    })
    defer client.Close()

    err = client.Set(ctx, "key", "value", 0).Err()
    if err != nil {
        t.Fatalf("Failed to set key: %v", err)
    }

    val, err := client.Get(ctx, "key").Result()
    if err != nil {
        t.Fatalf("Failed to get key: %v", err)
    }

    if val != "value" {
        t.Errorf("Expected value %q, but got %q", "value", val)
    }
}
</code></pre>
<p>运行该测试将看到类似如下结果：</p>
<pre><code>$go test
2023/04/15 16:18:20 github.com/testcontainers/testcontainers-go - Connected to docker:
  Server Version: 20.10.8
  API Version: 1.41
  Operating System: Ubuntu 20.04.3 LTS
  Total Memory: 10632 MB
2023/04/15 16:18:21 Failed to get image auth for docker.io. Setting empty credentials for the image: docker.io/testcontainers/ryuk:0.3.4. Error is:credentials not found in native keychain

2023/04/15 16:19:06 Starting container id: 0d8341b2270e image: docker.io/testcontainers/ryuk:0.3.4
2023/04/15 16:19:10 Waiting for container id 0d8341b2270e image: docker.io/testcontainers/ryuk:0.3.4
2023/04/15 16:19:10 Container is ready id: 0d8341b2270e image: docker.io/testcontainers/ryuk:0.3.4
2023/04/15 16:19:28 Starting container id: 999cf02b5a82 image: redis:latest
2023/04/15 16:19:30 Waiting for container id 999cf02b5a82 image: redis:latest
2023/04/15 16:19:30 Container is ready id: 999cf02b5a82 image: redis:latest
PASS
ok      demo    73.262s
</code></pre>
<p>我们看到建立这种真实版的“fake object”的一大不足就是依赖网络下载container image且耗时过长，在单元测试阶段使用还是要谨慎一些。testcontainer更多也会被用在集成测试或冒烟测试上。</p>
<p>一些开源项目，比如etcd，也提供了<a href="https://github.com/etcd-io/etcd/blob/main/tests/integration/embed">用于测试的自身简化版的实现(embed)</a>。这一点也值得我们效仿，在团队内部每个服务的开发者如果都能提供一个服务的简化版实现，那么对于该服务调用者来说，它的单测就会变得十分容易。</p>
<h2>5. 参考资料</h2>
<ul>
<li>《xUnit Test Patterns : Refactoring Test Code》- https://book.douban.com/subject/1859393/</li>
<li>Test Double Patterns &#8211; http://xunitpatterns.com/Test%20Double%20Patterns.html</li>
<li>The Unit in Unit Testing &#8211; https://www.infoq.com/articles/unit-testing-approach/</li>
<li>Test Doubles — Fakes, Mocks and Stubs &#8211; https://blog.pragmatists.com/test-doubles-fakes-mocks-and-stubs-1a7491dfa3da</li>
</ul>
<hr />
<p><a href="https://wx.zsxq.com/dweb2/index/group/51284458844544">“Gopher部落”知识星球</a>旨在打造一个精品Go学习和进阶社群！高品质首发Go技术文章，“三天”首发阅读权，每年两期Go语言发展现状分析，每天提前1小时阅读到新鲜的Gopher日报，网课、技术专栏、图书内容前瞻，六小时内必答保证等满足你关于Go语言生态的所有需求！2023年，Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码，关注代码质量并深入理解Go核心技术，并继续加强与星友的互动。欢迎大家加入！</p>
<p><img src="http://image.tonybai.com/img/tonybai/gopher-tribe-zsxq-small-card.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/go-programming-from-beginner-to-master-qr.png" alt="img{512x368}" /></p>
<p><img src="http://image.tonybai.com/img/tonybai/go-first-course-banner.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/imooc-go-column-pgo-with-qr.jpg" alt="img{512x368}" /></p>
<p>著名云主机服务厂商DigitalOcean发布最新的主机计划，入门级Droplet配置升级为：1 core CPU、1G内存、25G高速SSD，价格5$/月。有使用DigitalOcean需求的朋友，可以打开这个<a href="https://m.do.co/c/bff6eed92687">链接地址</a>：https://m.do.co/c/bff6eed92687 开启你的DO主机之路。</p>
<p>Gopher Daily(Gopher每日新闻)归档仓库 &#8211; https://github.com/bigwhite/gopherdaily</p>
<p>我的联系方式：</p>
<ul>
<li>微博(暂不可用)：https://weibo.com/bigwhite20xx</li>
<li>微博2：https://weibo.com/u/6484441286</li>
<li>博客：tonybai.com</li>
<li>github: https://github.com/bigwhite</li>
</ul>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。</p>
<p style='text-align:left'>&copy; 2023, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2023/04/20/provide-fake-object-for-external-collaborators/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>Go开发命令行程序指南</title>
		<link>https://tonybai.com/2023/03/25/the-guide-of-developing-cli-program-in-go/</link>
		<comments>https://tonybai.com/2023/03/25/the-guide-of-developing-cli-program-in-go/#comments</comments>
		<pubDate>Fri, 24 Mar 2023 22:35:58 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[AIGC]]></category>
		<category><![CDATA[argument]]></category>
		<category><![CDATA[benchmark]]></category>
		<category><![CDATA[brew]]></category>
		<category><![CDATA[ChatGPT]]></category>
		<category><![CDATA[cli]]></category>
		<category><![CDATA[clig.dev]]></category>
		<category><![CDATA[cobra]]></category>
		<category><![CDATA[Context]]></category>
		<category><![CDATA[coverage]]></category>
		<category><![CDATA[curl]]></category>
		<category><![CDATA[defer]]></category>
		<category><![CDATA[docker]]></category>
		<category><![CDATA[errors]]></category>
		<category><![CDATA[flag]]></category>
		<category><![CDATA[fmt]]></category>
		<category><![CDATA[Git]]></category>
		<category><![CDATA[GNU]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[go-module]]></category>
		<category><![CDATA[go.mod]]></category>
		<category><![CDATA[go1.11]]></category>
		<category><![CDATA[go1.20]]></category>
		<category><![CDATA[gobuild]]></category>
		<category><![CDATA[goinstall]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[GOPATH]]></category>
		<category><![CDATA[goreleaser]]></category>
		<category><![CDATA[GPT-4]]></category>
		<category><![CDATA[grep]]></category>
		<category><![CDATA[help]]></category>
		<category><![CDATA[Interface]]></category>
		<category><![CDATA[kingpin]]></category>
		<category><![CDATA[kubectl]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[log]]></category>
		<category><![CDATA[Mock]]></category>
		<category><![CDATA[option]]></category>
		<category><![CDATA[pkg]]></category>
		<category><![CDATA[POSIX]]></category>
		<category><![CDATA[README]]></category>
		<category><![CDATA[Signal]]></category>
		<category><![CDATA[subcommand]]></category>
		<category><![CDATA[subtest]]></category>
		<category><![CDATA[tar]]></category>
		<category><![CDATA[testify]]></category>
		<category><![CDATA[Testing]]></category>
		<category><![CDATA[Unix]]></category>
		<category><![CDATA[usage]]></category>
		<category><![CDATA[主命令]]></category>
		<category><![CDATA[优雅退出]]></category>
		<category><![CDATA[依赖管理]]></category>
		<category><![CDATA[参数]]></category>
		<category><![CDATA[命令行程序]]></category>
		<category><![CDATA[子命令]]></category>
		<category><![CDATA[标志]]></category>
		<category><![CDATA[表驱动测试]]></category>
		<category><![CDATA[选项]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=3837</guid>
		<description><![CDATA[注：上面篇首配图的底图由百度文心一格生成。 本文永久链接 &#8211; https://tonybai.com/2023/03/25/the-guide-of-developing-cli-program-in-go 近期在Twitter上看到一个名为“Command Line Interface Guidelines”的站点，这个站点汇聚了帮助大家编写出更好命令行程序的哲学与指南。这份指南基于传统的Unix编程原则，又结合现代的情况进行了“与时俱进”的更新。之前我还真未就如何编写命令行交互程序做系统的梳理，在这篇文章中，我们就来结合clig这份指南，(可能不会全面覆盖)整理出一份使用Go语言编写CLI程序的指南，供大家参考。 一. 命令行程序简介 命令行接口（Command Line Interface, 简称CLI）程序是一种允许用户使用文本命令和参数与计算机系统互动的软件。开发人员编写CLI程序通常用在自动化脚本、数据处理、系统管理和其他需要低级控制和灵活性的任务上。命令行程序也是Linux/Unix管理员以及后端开发人员的最爱。 2022年Q2 Go官方用户调查结果显示(如下图)：在使用Go开发的程序类别上，CLI类程序排行第二，得票率60%。 之所以这样，得益于Go语言为CLI开发提供的诸多便利，比如： Go语法简单而富有表现力； Go拥有一个强大的标准库，并内置的并发支持； Go拥有几乎最好的跨平台兼容性和快速的编译速度； Go还有一个丰富的第三方软件包和工具的生态系统。 这些都让开发者使用Go创建强大和用户友好的CLI程序变得容易。 容易归容易，但要用Go编写出优秀的CLI程序，我们还需要遵循一些原则，获得一些关于Go CLI程序开发的最佳实践和惯例。这些原则和惯例涉及交互界面设计、错误处理、文档、测试和发布等主题。此外，借助于一些流行的Go CLI程序开发库和框架，比如：cobra、Kingpin和Goreleaser等，我们可以又好又快地完成CLI程序的开发。在本文结束时，你将学会如何创建一个易于使用、可靠和可维护的Go CLI程序，你还将获得一些关于CLI开发的最佳实践和惯例的见解。 二. 建立Go开发环境 如果你读过《十分钟入门Go语言》或订阅学习过我的极客时间《Go语言第一课》专栏，你大可忽略这一节的内容。 在我们开始编写Go CLI程序之前，我们需要确保我们的系统中已经安装和配置了必要的Go工具和依赖。在本节中，我们将向你展示如何安装Go和设置你的工作空间，如何使用go mod进行依赖管理，以及如何使用go build和go install来编译和安装你的程序。 1. 安装Go 要在你的系统上安装Go，你可以遵循你所用操作系统的官方安装说明。你也可以使用软件包管理器，如homebrew（用于macOS）、chocolatey（用于Windows）或snap/apt（用于Linux）来更容易地安装Go。 一旦你安装了Go，你可以通过在终端运行以下命令来验证它是否可以正常工作。 $go version 如果安装成功，go version这个命令应该会打印出你所安装的Go的版本。比如说： go version go1.20 darwin/amd64 2. 设置你的工作区(workspace) Go以前有一个惯例，即在工作区目录中(\$GOPATH)组织你的代码和依赖关系。默认工作空间目录位于$HOME/go，但你可以通过设置GOPATH环境变量来改变它的路径。工作区目录包含三个子目录：src、pkg和bin。src目录包含了你的源代码文件和目录。pkg目录包含被你的代码导入的已编译好的包。bin目录包含由你的代码生成的可执行二进制文件。 Go 1.11引入Go module后，这种在\$GOPATH下组织代码和寻找依赖关系的要求被彻底取消。在这篇文章中，我依旧按照我的习惯在$HOME/go/src下放置我的代码示例。 为了给我们的CLI程序创建一个新的项目目录，我们可以在终端运行以下命令： $mkdir -p [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/the-guide-of-developing-cli-program-in-go-1.png" alt="" /></p>
<blockquote>
<p>注：上面篇首配图的底图由百度<a href="https://yige.baidu.com">文心一格</a>生成。</p>
</blockquote>
<p><a href="https://tonybai.com/2023/03/25/the-guide-of-developing-cli-program-in-go">本文永久链接</a> &#8211; https://tonybai.com/2023/03/25/the-guide-of-developing-cli-program-in-go</p>
<p>近期在Twitter上看到一个名为<a href="https://clig.dev/">“Command Line Interface Guidelines”的站点</a>，这个站点汇聚了帮助大家编写出更好命令行程序的哲学与指南。这份指南<a href="https://book.douban.com/subject/1467587/">基于传统的Unix编程原则</a>，又结合现代的情况进行了“与时俱进”的更新。之前我还真未就如何编写命令行交互程序做系统的梳理，在这篇文章中，我们就来结合<a href="https://clig.dev/">clig这份指南</a>，(可能不会全面覆盖)整理出一份使用Go语言编写CLI程序的指南，供大家参考。</p>
<h2>一. 命令行程序简介</h2>
<p>命令行接口（Command Line Interface, 简称CLI）程序是一种允许用户使用文本命令和参数与计算机系统互动的软件。开发人员编写CLI程序通常用在自动化脚本、数据处理、系统管理和其他需要低级控制和灵活性的任务上。<strong>命令行程序也是Linux/Unix管理员以及后端开发人员的最爱</strong>。</p>
<p><a href="https://go.dev/blog/survey2022-q2-results">2022年Q2 Go官方用户调查结果</a>显示(如下图)：在使用Go开发的程序类别上，CLI类程序排行第二，得票率60%。</p>
<p><img src="https://tonybai.com/wp-content/uploads/the-guide-of-developing-cli-program-in-go-2.png" alt="" /></p>
<p>之所以这样，得益于Go语言为CLI开发提供的诸多便利，比如：</p>
<ul>
<li>Go语法简单而富有表现力；</li>
<li>Go拥有一个强大的标准库，并内置的并发支持；</li>
<li>Go拥有几乎最好的跨平台兼容性和快速的编译速度；</li>
<li>Go还有一个丰富的第三方软件包和工具的生态系统。</li>
</ul>
<p>这些都让开发者使用Go创建强大和用户友好的CLI程序变得容易。</p>
<p>容易归容易，但要用Go编写出优秀的CLI程序，我们还需要遵循一些原则，获得一些关于Go CLI程序开发的最佳实践和惯例。这些原则和惯例涉及交互界面设计、错误处理、文档、测试和发布等主题。此外，借助于一些流行的Go CLI程序开发库和框架，比如：<a href="https://github.com/spf13/cobra">cobra</a>、<a href="https://github.com/alecthomas/kingpin">Kingpin</a>和<a href="https://github.com/goreleaser/goreleaser">Goreleaser</a>等，我们可以又好又快地完成CLI程序的开发。在本文结束时，你将学会如何创建一个易于使用、可靠和可维护的Go CLI程序，你还将获得一些关于CLI开发的最佳实践和惯例的见解。</p>
<h2>二. 建立Go开发环境</h2>
<blockquote>
<p>如果你读过<a href="https://tonybai.com/2023/02/23/learn-go-in-10-min">《十分钟入门Go语言》</a>或订阅学习过我的极客时间<a href="http://gk.link/a/10AVZ">《Go语言第一课》专栏</a>，你大可忽略这一节的内容。</p>
</blockquote>
<p>在我们开始编写Go CLI程序之前，我们需要确保我们的系统中已经安装和配置了必要的Go工具和依赖。在本节中，我们将向你展示如何安装Go和设置你的工作空间，如何<a href="https://tonybai.com/2022/03/12/dependency-hell-in-go/">使用go mod进行依赖管理</a>，以及如何使用go build和go install来编译和安装你的程序。</p>
<h3>1. 安装Go</h3>
<p>要在你的系统上安装Go，你可以遵循你所用操作系统的官方安装说明。你也可以使用软件包管理器，如<a href="https://brew.sh">homebrew</a>（用于macOS）、chocolatey（用于Windows）或snap/apt（用于Linux）来更容易地安装Go。</p>
<p>一旦你安装了Go，你可以通过在终端运行以下命令来验证它是否可以正常工作。</p>
<pre><code>$go version
</code></pre>
<p>如果安装成功，go version这个命令应该会打印出你所安装的Go的版本。比如说：</p>
<pre><code>go version go1.20 darwin/amd64
</code></pre>
<h3>2. 设置你的工作区(workspace)</h3>
<p>Go以前有一个惯例，即在工作区目录中(\$GOPATH)组织你的代码和依赖关系。默认工作空间目录位于$HOME/go，但你可以通过设置GOPATH环境变量来改变它的路径。工作区目录包含三个子目录：src、pkg和bin。src目录包含了你的源代码文件和目录。pkg目录包含被你的代码导入的已编译好的包。bin目录包含由你的代码生成的可执行二进制文件。</p>
<p><a href="https://tonybai.com/2018/11/19/some-changes-in-go-1-11">Go 1.11引入Go module</a>后，这种在\$GOPATH下组织代码和寻找依赖关系的要求被彻底取消。在这篇文章中，我依旧按照我的习惯在$HOME/go/src下放置我的代码示例。</p>
<p>为了给我们的CLI程序创建一个新的项目目录，我们可以在终端运行以下命令：</p>
<pre><code>$mkdir -p $HOME/go/src/github.com/your-username/your-li-program
$cd $HOME/go/src/github.com/your-username/your-cli-program
</code></pre>
<p>注意，我们的项目目录名使用的是github的URL格式。这在Go项目中是一种常见的做法，因为它使得使用go get导入和管理依赖关系更加容易。go module成为构建标准后，这种对项目目录名的要求已经取消，但很多Gopher依旧保留了这种作法。</p>
<h3>3. 使用go mod进行依赖管理</h3>
<p>1.11版本后Go推荐开发者使用module来管理包的依赖关系。一个module是共享一个共同版本号和导入路径前缀的相关包的集合。一个module是由一个叫做go.mod的文件定义的，它指定了模块的名称、版本和依赖关系。</p>
<p>为了给我们的CLI程序创建一个新的module，我们可以在我们的项目目录下运行以下命令。</p>
<pre><code>$go mod init github.com/your-username/your-cli-program
</code></pre>
<p>这将创建一个名为go.mod的文件，内容如下。</p>
<pre><code>module github.com/your-username/your-cli-program

go 1.20
</code></pre>
<p>第一行指定了我们的module名称，这与我们的项目目录名称相匹配。第二行指定了构建我们的module所需的Go的最低版本。</p>
<p>为了给我们的模块添加依赖项，我们可以使用go get命令，加上我们想使用的软件包的导入路径和可选的版本标签。例如，如果我们想使用<a href="https://github.com/spf13/cobra">cobra</a>作为我们的CLI框架，我们可以运行如下命令：</p>
<pre><code>$go get github.com/spf13/cobra@v1.3.0
</code></pre>
<p>go get将从github下载cobra，并在我们的go.mod文件中把它作为一个依赖项添加进去。它还将创建或更新一个名为go.sum的文件，记录所有下载的module的校验和，以供后续验证使用。</p>
<p>我们还可以使用其他命令，如go list、go mod tidy、go mod graph等，以更方便地检查和管理我们的依赖关系。</p>
<h3>4. 使用go build和go install来编译和安装你的程序</h3>
<p>Go有两个命令允许你编译和安装你的程序：go build和go install。这两个命令都以一个或多个包名或导入路径作为参数，并从中产生可执行的二进制文件。</p>
<p>它们之间的主要区别在于它们将生成的二进制文件存储在哪里。</p>
<ul>
<li>go build将它们存储在当前工作目录中。</li>
<li>go install将它们存储在\$GOPATH/bin或\$GOBIN（如果设置了）。</li>
</ul>
<p>例如，如果我们想把CLI程序的main包（应该位于github.com/your-username/your-cli-program/cmd/your-cli-program）编译成一个可执行的二进制文件，称为your-cli-program，我们可以运行下面命令：</p>
<pre><code>$go build github.com/your-username/your-cli-program/cmd/your-cli-program
</code></pre>
<p>或</p>
<pre><code>$go install github.com/your-username/your-cli-program/cmd/your-cli-program@latest
</code></pre>
<h2>三. 设计用户接口(interface)</h2>
<p>要编写出一个好的CLI程序，最重要的环节之一是<strong><a href="https://clig.dev/#human-first-design">设计一个用户友好的接口</a></strong>。好的命令行用户接口应该是<strong>一致的、直观的和富有表现力的</strong>。在本节中，我将说明如何为命令行程序命名和选择命令结构(command structure)，如何使用标志(flag)、参数(argument)、子命令(subcommand)和选项(option)作为输入参数，如何使用cobra或Kingpin等来解析和验证用户输入，以及如何遵循POSIX惯例和GNU扩展的CLI语法。</p>
<h3>1. 命令行程序命名和命令结构选择</h3>
<p>你的CLI程序的名字应该是<strong><a href="https://clig.dev/#naming">简短、易记、描述性的和易输入的</a></strong>。它应该避免与目标平台中现有的命令或关键字发生冲突。例如，如果你正在编写一个在不同格式之间转换图像的程序，你可以把它命名为imgconv、imago、picto等，但不能叫image、convert或format。</p>
<p>你的CLI程序的命令结构应该反映你想提供给用户的主要功能特性。你可以选择使用下面命令结构模式中的一种：</p>
<ul>
<li>一个带有多个标志(flag)和参数(argument)的单一命令（例如：curl、tar、grep等)</li>
<li>带有多个子命令(subcommand)的单一命令（例如：git、docker、kubectl等)</li>
<li>具有共同前缀的多个命令（例如：aws s3、gcloud compute、az vm等)</li>
</ul>
<p>命令结构模式的选择取决于你的程序的复杂性和使用范围，一般来说：</p>
<ul>
<li>如果你的程序只有一个主要功能或操作模式(operation mode)，你可以使用带有多个标志和参数的单一命令。</li>
<li>如果你的程序有多个相关但又不同的功能或操作模式，你可以使用一个带有多个子命令的单一命令。</li>
<li>如果你的程序有多个不相关或独立的功能或操作模式，你可以使用具有共同前缀的多个命令。</li>
</ul>
<p>例如，如果你正在编写一个对文件进行各种操作的程序（如复制、移动、删除），你可以任选下面命令结构模式中的一种：</p>
<ul>
<li>带有多个标志和参数的单一命令（例如，fileop -c src dst -m src dst -d src)</li>
<li>带有多个子命令的单个命令（例如，fileop copy src dst, fileop move src dst, fileop delete src)</li>
</ul>
<h3>2. 使用标志、参数、子命令和选项</h3>
<p><strong>标志(flag)</strong>是以一个或多个(通常是2个)中划线（-）开头的输入参数，它可以修改CLI程序的行为或输出。例如：</p>
<pre><code>$curl -s -o output.txt https://example.com
</code></pre>
<p>在这个例子中：</p>
<ul>
<li>“-s”是一个让curl沉默的标志，即不输出执行日志到控制台；</li>
<li>“-o”是另一个标志，用于指定输出文件的名称</li>
<li>“output.txt”则是一个参数，是为“-o”标志提供的值。</li>
</ul>
<p><strong>参数(argument)</strong>是不以中划线（-）开头的输入参数，为你的CLI程序提供额外的信息或数据。例如：</p>
<pre><code>$tar xvf archive.tar.gz
</code></pre>
<p>我们看在这个例子中：</p>
<ul>
<li>x是一个指定提取模式的参数</li>
<li>v是一个参数，指定的是输出内容的详细(verbose)程度</li>
<li>f是另一个参数，用于指定采用的是文件模式，即将压缩结果输出到一个文件或从一个压缩文件读取数据</li>
<li>archive.tar.gz是一个参数，提供文件名。</li>
</ul>
<p><strong>子命令(subcommand)</strong>是输入参数，作为主命令下的辅助命令。它们通常有自己的一组标志和参数。比如下面例子：</p>
<pre><code>$git commit -m "Initial commit"
</code></pre>
<p>我们看在这个例子中：</p>
<ul>
<li>git是主命令(primary command)</li>
<li>commit是一个子命令，用于从staged的修改中创建一个新的提交(commit)</li>
<li>“-m”是commit子命令的一个标志，用于指定提交信息</li>
<li>“Initial commit”是commit子命令的一个参数，为”-m”标志提供值。</li>
</ul>
<p><strong>选项(option)</strong>是输入参数，它可以使用等号（=）将标志和参数合并为一个参数。例如:</p>
<pre><code>$docker run --name=my-container ubuntu:latest
</code></pre>
<p>我们看在这个例子中“&#8211;name=my-container”是一个选项，它将容器的名称设为my-container。该选项前面的部分“&#8211;name”是一个标志，后面的部分“my-container”是参数。</p>
<h3>3. 使用cobra包等来解析和验证用户输入的信息</h3>
<p>如果手工来解析和验证用户输入的信息，既繁琐又容易出错。幸运的是，有许多库和框架可以帮助你在Go中解析和验证用户输入。其中最流行的是<a href="https://github.com/spf13/cobra">cobra</a>。</p>
<p>cobra是一个Go包，它提供了简单的接口来创建强大的CLI程序。它支持子命令、标志、参数、选项、环境变量和配置文件。它还能很好地与其他库集成，比如：<a href="https://tonybai.com/2022/09/20/use-viper-to-do-merge-of-yml-configuration-files/">viper</a>（用于配置管理）、<a href="https://github.com/spf13/pflag">pflag</a>（用于POSIX/GNU风格的标志）和<a href="https://github.com/docopt/docopt.go">Docopt</a>（用于生成文档）。</p>
<p>另一个不那么流行但却提供了一种声明式的方法来创建优雅的CLI程序的包是<a href="https://github.com/alecthomas/kingpin">Kingpin</a>，它支持标志、参数、选项、环境变量和配置文件。它还具有自动帮助生成、命令完成、错误处理和类型转换等功能。</p>
<p>cobra和Kingpin在其官方网站上都有大量的文档和例子，你可以根据你的偏好和需要选择任选其一。</p>
<h3>4. 遵循POSIX惯例和GNU扩展的CLI语法</h3>
<p><a href="http://get.posixcertified.ieee.org">POSIX（Portable Operating System Interface）</a>是一套标准，定义了软件应该如何与操作系统进行交互。其中一个标准定义了CLI程序的语法和语义。GNU（GNU&#8217;s Not Unix）是一个旨在创建一个与UNIX兼容的自由软件操作系统的项目。GNU下的一个子项目是<a href="https://www.gnu.org/software/coreutils/">GNU Coreutils</a>，它提供了许多常见的CLI程序，如ls、cp、mv等。</p>
<p>POSIX和GNU都为CLI语法建立了一些约定和扩展，许多CLI程序都采用了这些约定与扩展。下面列举了这些约定和扩展中的一些主要内容：</p>
<ul>
<li>单字母标志(single-letter flag)以一个中划线（-）开始，可以组合在一起（例如：-a -b -c 或 -abc )</li>
<li>长标志(long flag)以两个中划线（&#8211;）开头，但不能组合在一起（例如：&#8211;all、&#8211;backup、&#8211;color )</li>
<li>选项使用等号(=)来分隔标志名和参数值(例如：&#8211;name=my-container )</li>
<li>参数跟在标志或选项之后，没有任何分隔符（例如：curl -o output.txt https://example.com ）。</li>
<li>子命令跟在主命令之后，没有任何分隔符（例如：git commit -m “Initial commit” )</li>
<li>一个双中划线（&#8211;）表示标志或选项的结束和参数的开始（例如：rm &#8212; -f 表示要删除“-f”这个文件，由于双破折线的存在，这里的“-f”不再是标志)</li>
</ul>
<p>遵循这些约定和扩展可以使你的CLI程序更加一致、直观，并与其他CLI程序兼容。然而，它们并不是强制性的，如果你有充分的理由，你也大可不必完全遵守它们。例如，一些CLI程序使用斜线（/）而不是中划线（-）表示标志（例如， robocopy /S /E src dst ）。</p>
<h2>四. 处理错误和信号</h2>
<p>编写好的CLI程序的一个重要环节就是<strong><a href="https://clig.dev/#errors">优雅地处理错误和信号</a></strong>。</p>
<p>错误是指你的程序由于某些内部或外部因素而无法执行其预定功能的情况。信号是由操作系统或其他进程向你的程序发送的事件，以通知它一些变化或请求。在这一节中，我将说明一下如何使用log、fmt和errors包进行日志输出和错误处理，如何使用os.Exit和defer语句进行优雅的终止，如何使用os.Signal和context包进行中断和取消操作，以及如何遵循CLI程序的退出状态代码惯例。</p>
<h3>1. 使用log、fmt和errors包进行日志记录和错误处理</h3>
<p>Go标准库中有三个包log、fmt和errors可以帮助你进行日志和错误处理。log包提供了一个简单的接口，可以将格式化的信息写到标准输出或文件中。fmt包则提供了各种格式化字符串和值的函数。errors包提供了创建和操作错误值的函数。</p>
<p>要使用log包，你需要在你的代码中导入它：</p>
<pre><code>import "log"
</code></pre>
<p>然后你可以使用log.Println、log.Printf、log.Fatal和log.Fatalf等函数来输出不同严重程度的信息。比如说：</p>
<pre><code>log.Println("Starting the program...") // 打印带有时间戳的消息
log.Printf("Processing file %s...\n", filename) // 打印一个带时间戳的格式化信息
log.Fatal("Cannot open file: ", err) // 打印一个带有时间戳的错误信息并退出程序
log.Fatalf("Invalid input: %v\n", input) // 打印一个带时间戳的格式化错误信息，并退出程序。
</code></pre>
<p>为了使用fmt包，你需要先在你的代码中导入它：</p>
<pre><code>import "fmt"
</code></pre>
<p>然后你可以使用fmt.Println、fmt.Printf、fmt.Sprintln、fmt.Sprintf等函数以各种方式格式化字符串和值。比如说：</p>
<pre><code>fmt.Println("Hello world!") // 打印一条信息，后面加一个换行符
fmt.Printf("The answer is %d\n", 42) // 打印一条格式化的信息，后面是换行。
s := fmt.Sprintln("Hello world!") // 返回一个带有信息和换行符的字符串。
t := fmt.Sprintf("The answer is %d\n", 42) // 返回一个带有格式化信息和换行的字符串。
</code></pre>
<p>要使用错误包，你同样需要在你的代码中导入它：</p>
<pre><code>import "errors"
</code></pre>
<p>然后你可以使用 errors.New、errors.Unwrap、errors.Is等函数来创建和操作错误值。比如说：</p>
<pre><code>err := errors.New("Something went wrong") // 创建一个带有信息的错误值
cause := errors.Unwrap(err) // 返回错误值的基本原因（如果没有则为nil）。
match := errors.Is(err, io.EOF) // 如果一个错误值与另一个错误值匹配，则返回真（否则返回假）。
</code></pre>
<h3>2. 使用os.Exit和defer语句实现CLI程序的优雅终止</h3>
<p>Go有两个功能可以帮助你优雅地终止CLI程序：os.Exit和defer。os.Exit函数立即退出程序，并给出退出状态代码。defer语句则会在当前函数退出前执行一个函数调用，它常用来执行清理收尾动作，如关闭文件或释放资源。</p>
<p>要使用os.Exit函数，你需要在你的代码中导入os包：</p>
<pre><code>import "os"
</code></pre>
<p>然后你可以使用os.Exit函数，它的整数参数代表退出状态代码。比如说</p>
<pre><code>os.Exit(0) // 以成功的代码退出程序
os.Exit(1) // 以失败代码退出程序
</code></pre>
<p>要使用defer语句，你需要把它写在你想后续执行的函数调用之前。比如说</p>
<pre><code>file, err := os.Open(filename) // 打开一个文件供读取。
if err != nil {
    log.Fatal(err) // 发生错误时退出程序
}
defer file.Close() // 在函数结束时关闭文件。

// 对文件做一些处理...
</code></pre>
<h3>3. 使用os.signal和context包来实现中断和取消操作</h3>
<p>Go有两个包可以帮助你实现中断和取消长期运行的或阻塞的操作，它们是os.signal和context包。os.signal提供了一种从操作系统或其他进程接收信号的方法。context包提供了一种跨越API边界传递取消信号和deadline的方法。</p>
<p>要使用os.signal，你需要先在你的代码中导入它。</p>
<pre><code>import (
  "os"
  "os/signal"
)
</code></pre>
<p>然后你可以使用signal.Notify函数针对感兴趣的信号(如下面的os.Interrupt信号)注册一个接收channel(sig)。比如说：</p>
<pre><code>sig := make(chan os.Signal, 1) // 创建一个带缓冲的信号channel。
signal.Notify(sig, os.Interrupt) // 注册sig以接收中断信号（例如Ctrl-C）。

// 做一些事情...

select {
case &lt;-sig: // 等待来自sig channel的信号
    fmt.Println("被用户中断了")
    os.Exit(1) // 以失败代码退出程序。
default: //如果没有收到信号就执行
    fmt.Println("成功完成")
    os.Exit(0) // 以成功代码退出程序。
}
</code></pre>
<p>要使用上下文包，你需要在你的代码中导入它：</p>
<pre><code>import "context"
</code></pre>
<p>然后你可以使用它的函数，如context.Background、context.WithCancel、context.WithTimeout等来创建和管理Context。Context是一个携带取消信号和deadline的对象，可以跨越API边界。比如说：</p>
<pre><code>ctx := context.Background() // 创建一个空的背景上下文（从不取消）。
ctx, cancel := context.WithCancel(ctx) // 创建一个新的上下文，可以通过调用cancel函数来取消。
defer cancel() // 在函数结束前执行ctx的取消动作

// 将ctx传递给一些接受它作为参数的函数......

select {
case &lt;-ctx.Done(): // 等待来自ctx的取消信号
    fmt.Println("Canceled by parent")
    return ctx.Err() // 从ctx返回一个错误值
default: // 如果没有收到取消信号就执行
    fmt.Println("成功完成")
    return nil // 不返回错误值
}
</code></pre>
<h3>4. CLI程序的退出状态代码惯例</h3>
<p>退出状态代码是一个整数，表示CLI程序是否成功执行完成。CLI程序通过调用os.Exit或从main返回的方式返回退出状态值。其他CLI程序或脚本可以可以检查这些退出状态码，并根据状态码值的不同执行不同的处理操作。</p>
<p>业界有一些关于退出状态代码的约定和扩展，这些约定被许多CLI程序广泛采用。其中一些主要的约定和扩展如下：。</p>
<ul>
<li>退出状态代码为0表示程序执行成功（例如：os.Exit(0) )</li>
<li>非零的退出状态代码表示失败（例如：os.Exit(1) ）。</li>
<li>不同的非零退出状态代码可能表示不同的失败类型或原因（例如：os.Exit(2)表示使用错误，os.Exit(3)表示权限错误等等）。</li>
<li>大于125的退出状态代码可能表示被外部信号终止（例如，os.Exit(130)为被信号中断）。</li>
</ul>
<p>遵循这些约定和扩展可以使你的CLI程序表现的更加一致、可靠并与其他CLI程序兼容。然而，它们不是强制性的，你可以使用任何对你的程序有意义的退出状态代码。例如，一些CLI程序使用高于200的退出状态代码来表示自定义或特定应用的错误（例如，os.Exit(255)表示未知错误）。</p>
<h2>五. 编写文档</h2>
<p>编写优秀CLI程序的另一个重要环节是编写清晰简洁的文档，解释你的程序做什么以及如何使用它。文档可以采取各种形式，如README文件、usage信息、help flag等。在本节中，我们将告诉你如何为你的程序写一个README文件，如何为你的程序写一个有用的usage和help flag等。</p>
<h3>1. 为你的CLI程序写一个清晰简洁的README文件</h3>
<p>README文件是一个文本文件，它提供了关于你的程序的基本信息，如它的名称、描述、用法、安装、依赖性、许可证和联系细节等。它通常是用户或开发者在源代码库或软件包管理器上首次使用你的程序时会看到的内容。</p>
<p>如果你要为Go CLI程序编写一个优秀的README文件，你应该遵循一些最佳实践，比如：</p>
<ul>
<li>使用一个描述性的、醒目的标题，反映你的程序的目的和功能。</li>
<li>提供一个简短的介绍，解释你的程序是做什么的，为什么它是有用的或独特的。</li>
<li>包括一个usage部分，说明如何用不同的标志、参数、子命令和选项来调用你的程序。你可以使用代码块或屏幕截图来说明这些例子。</li>
<li>包括一个安装(install)部分，解释如何在不同的平台上下载和安装你的程序。你可以使用go install、go get、<a href="https://github.com/goreleaser/goreleaser">goreleaser</a>或其他工具来简化这一过程。</li>
<li>指定你的程序的发行许可，并提供一个许可全文的链接。你可以使用<a href="https://spdx.dev/">SPDX标识符</a>来表示许可证类型。</li>
<li>为想要报告问题、请求新功能、贡献代码或提问的用户或开发者提供联系信息。你可以使用github issue、pr、discussion、电子邮件或其他渠道来达到这个目的。</li>
</ul>
<p>以下是一个Go CLI程序的README文件的示例供参考：</p>
<p><img src="https://tonybai.com/wp-content/uploads/the-guide-of-developing-cli-program-in-go-3.png" alt="" /></p>
<h3>2. 为你的CLI程序编写有用的usage和help标志</h3>
<p>usage信息是一段简短的文字，总结了如何使用你的程序及其可用的标志、参数、子命令和选项。它通常在你的程序在没有参数或输入无效的情况下运行时显示。</p>
<p>help标志是一个特殊的标志（通常是-h或&#8211;help），它可以触发显示使用信息和一些关于你的程序的额外信息。</p>
<p>为了给你的Go CLI程序写有用的usage信息和help标志，你应该遵循一些准则，比如说：</p>
<ul>
<li>使用一致而简洁的语法来描述标志、参数、子命令和选项。你可以用方括号“[ ]”表示可选元素，使用角括号“&lt; >”表示必需元素，使用省略号“&#8230;”表示重复元素，使用管道“|”表示备选，使用中划线“-”表示标志(flag)，使用等号“=”表示标志的值等等。</li>
<li>对标志、参数、子命令和选项应使用描述性的名称，以反映其含义和功能。避免使用单字母名称，除非它们非常常见或非常直观（如-v按惯例表示verbose模式）。</li>
<li>为每个标志、参数、子命令和选项提供简短而清晰的描述，解释它们的作用以及它们如何影响你的程序的行为。你可以用圆括号“（ ）”来表达额外的细节或例子。</li>
<li>使用标题或缩进将相关的标志、参数、子命令和选项组合在一起。你也可以用空行或水平线（&#8212;）来分隔usage的不同部分。</li>
<li>在每组中按名称的字母顺序排列标志。在每组中按重要性或逻辑顺序排列参数。在每组中按使用频率排列子命令。</li>
</ul>
<p>git的usage就是一个很好的例子：</p>
<pre><code>$git
usage: git [--version] [--help] [-C &lt;path&gt;] [-c &lt;name&gt;=&lt;value&gt;]
           [--exec-path[=&lt;path&gt;]] [--html-path] [--man-path] [--info-path]
           [-p | --paginate | -P | --no-pager] [--no-replace-objects] [--bare]
           [--git-dir=&lt;path&gt;] [--work-tree=&lt;path&gt;] [--namespace=&lt;name&gt;]
           &lt;command&gt; [&lt;args&gt;]
</code></pre>
<p>结合上面的准则，大家可以细心体会一下。</p>
<h2>六. 测试和发布你的CLI程序</h2>
<p>编写优秀CLI程序的最后一个环节是测试和发布你的程序。测试确保你的程序可以按预期工作，并符合质量标准。发布可以使你的程序可供用户使用和访问。</p>
<p>在本节中，我将说明如何使用testing、testify/assert、mock包对你的代码进行单元测试，如何使用go test、coverage、benchmark工具来运行测试和测量程序性能以及如何使用goreleaser包来构建跨平台的二进制文件。</p>
<h3>1. 使用testing、testify的assert及mock包对你的代码进行单元测试</h3>
<p>单元测试是一种验证单个代码单元（如函数、方法或类型）的正确性和功能的技术。单元测试可以帮助你尽早发现错误，提高代码质量和可维护性，并促进重构和调试。</p>
<p>要为你的Go CLI程序编写单元测试，你应该遵循一些最佳实践：</p>
<ul>
<li>使用内置的测试包来创建测试函数，以Test开头，后面是被测试的函数或方法的名称。例如：func TestSum(t *testing.T) { &#8230; }；</li>
<li>使用&#42;testing.T类型的t参数，使用t.Error、t.Errorf、t.Fatal或t.Fatalf这样的方法报告测试失败。你也可以使用t.Log、t.Logf、t.Skip或t.Skipf这样的方法来提供额外的信息或有条件地跳过测试。</li>
<li>使用<a href="https://tonybai.com/2023/03/15/an-intro-of-go-subtest">Go子测试(sub test)</a>，通过t.Run方法将相关的测试分组。例如：</li>
</ul>
<pre><code>func TestSum(t *testing.T) {
    t.Run("positive numbers", func(t *testing.T) {
        // test sum with positive numbers
    })
    t.Run("negative numbers", func(t *testing.T) {
        // test sum with negative numbers
    })
}
</code></pre>
<ul>
<li>使用表格驱动(table-driven)的测试来运行多个测试用例，比如下面的例子：</li>
</ul>
<pre><code>func TestSum(t *testing.T) {
    tests := []struct{
        name string
        a int
        b int
        want int
    }{
        {"positive numbers", 1, 2, 3},
        {"negative numbers", -1, -2, -3},
        {"zero", 0, 0 ,0},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Sum(tt.a , tt.b)
            if got != tt.want {
                t.Errorf("Sum(%d , %d) = %d; want %d", tt.a , tt.b , got , tt.want)
            }
        })
    }
}
</code></pre>
<ul>
<li>使用外部包，如testify/assert或mock来简化你的断言或对外部的依赖性。比如说：</li>
</ul>
<pre><code>import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

type Calculator interface {
    Sum(a int , b int) int
}

type MockCalculator struct {
    mock.Mock
}

func (m *MockCalculator) Sum(a int , b int) int {
    args := m.Called(a , b)
    return args.Int(0)
}
</code></pre>
<h3>2. 使用Go的测试、覆盖率、性能基准工具来运行测试和测量性能</h3>
<p>Go提供了一套工具来运行测试和测量你的代码的性能。你可以使用这些工具来确保你的代码按预期工作，检测错误或bug，并优化你的代码以提高速度和效率。</p>
<p>要使用go test、coverage、benchmark工具来运行测试和测量你的Go CLI程序的性能，你应该遵循一些步骤，比如说。</p>
<ul>
<li>将以&#95;test.go结尾的测试文件写在与被测试代码相同的包中。例如：sum_test.go用于测试sum.go。</li>
<li>使用go测试命令来运行一个包中的所有测试或某个特定的测试文件。你也可以使用一些标志，如-v，用于显示verbose的输出，-run用于按名字过滤测试用例，-cover用于显示代码覆盖率，等等。例如：go test -v -cover ./&#8230;</li>
<li>使用go工具cover命令来生成代码覆盖率的HTML报告，并高亮显示代码行。你也可以使用-func这样的标志来显示函数的代码覆盖率，用-html还可以在浏览器中打开覆盖率结果报告等等。例如：go tool cover -html=coverage.out</li>
<li>编写性能基准函数，以Benchmark开头，后面是被测试的函数或方法的名称。使用类型为&#42;testing.B的参数b来控制迭代次数，并使用b.N、b.ReportAllocs等方法控制报告结果的输出。比如说</li>
</ul>
<pre><code>func BenchmarkSum(b *testing.B) {
    for i := 0; i &lt; b.N; i++ {
        Sum(1 , 2)
    }
}
</code></pre>
<ul>
<li>
<p>使用go test -bench命令来运行一个包中的所有性能基准测试或某个特定的基准文件。你也可以使用-benchmem这样的标志来显示内存分配的统计数据，-cpuprofile或-memprofile来生成CPU或内存profile文件等等。例如：go test -bench . -benchmem ./&#8230;</p>
</li>
<li>
<p>使用pprof或benchstat等工具来分析和比较CPU或内存profile文件或基准测试结果。比如说。</p>
</li>
</ul>
<pre><code># Generate CPU profile
go test -cpuprofile cpu.out ./...

# Analyze CPU profile using pprof
go tool pprof cpu.out

# Generate two sets of benchmark results
go test -bench . ./... &gt; old.txt
go test -bench . ./... &gt; new.txt

# Compare benchmark results using benchstat
benchstat old.txt new.txt
</code></pre>
<h3>3. 使用goreleaser包构建跨平台的二进制文件</h3>
<p>构建跨平台二进制文件意味着将你的代码编译成可执行文件，可以在不同的操作系统和架构上运行，如Windows、Linux、Mac OS、ARM等。这可以帮助你向更多的人分发你的程序，使用户更容易安装和运行你的程序而不需要任何依赖或配置。</p>
<p>为了给你的Go CLI程序建立跨平台的二进制文件，你可以使用外部软件包，比如goreleaser等 ，它们可以自动完成程序的构建、打包和发布过程。下面是使用goreleaser包构建程序的一些步骤。</p>
<ul>
<li>使用go get或go install命令安装goreleaser。例如： go install github.com/goreleaser/goreleaser@latest</li>
<li>创建一个配置文件（通常是.goreleaser.yml），指定如何构建和打包你的程序。你可以定制各种选项，如二进制名称、版本、主文件、输出格式、目标平台、压缩、校验和、签名等。例如。</li>
</ul>
<pre><code># .goreleaser.yml
project_name: mycli
builds:
  - main: ./cmd/mycli/main.go
    binary: mycli
    goos:
      - windows
      - darwin
      - linux
    goarch:
      - amd64
      - arm64
archives:
  - format: zip
    name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
    files:
      - LICENSE.txt
      - README.md
checksum:
  name_template: "{{ .ProjectName }}_checksums.txt"
  algorithm: sha256
</code></pre>
<p>运行goreleaser命令，根据配置文件构建和打包你的程序。你也可以使用-snapshot用于测试，-release-notes用于从提交信息中生成发布说明，-rm-dist用于删除之前的构建，等等。例如：goreleaser &#8211;snapshot &#8211;rm-dist。</p>
<p>检查输出文件夹（通常是dist）中生成的二进制文件和其他文件。你也可以使用goreleaser的发布功能将它们上传到源代码库或软件包管理器中。</p>
<h2>七. clig.dev指南要点</h2>
<p>通过上述的系统说明，你现在应该可以设计并使用Go实现出一个CLI程序了。不过本文并非覆盖了clig.dev指南的所有要点，因此，在结束本文之前，我们再来回顾一下clig.dev指南中的要点，大家再体会一下。</p>
<p>前面说过，clig.dev上的cli指南是一个开源指南，可以帮助你写出更好的命令行程序，它采用了传统的UNIX原则，并针对现代的情况进行了更新。</p>
<p>遵循cli准则的一些好处是：</p>
<ul>
<li>你可以创建易于使用、理解和记忆的CLI程序。</li>
<li>你可以设计出能与其他程序进行很好配合的CLI程序，并遵循共同的惯例。</li>
<li>你可以避免让用户和开发者感到沮丧的常见陷阱和错误。</li>
<li>你可以从其他CLI设计者和用户的经验和智慧中学习。</li>
</ul>
<p>下面是该指南的一些要点：</p>
<ul>
<li>理念</li>
</ul>
<p>这一部分解释了好的CLI设计背后的核心原则，如人本设计、可组合性、可发现性、对话性等。例如，以人为本的设计意味着CLI程序对人类来说应该易于使用和理解，而不仅仅是机器。可组合性意味着CLI程序应该通过遵循共同的惯例和标准与其他程序很好地协作。</p>
<ul>
<li>参数和标志</li>
</ul>
<p>这一部分讲述了如何在你的CLI程序中使用位置参数(positional arguments )和标志。它还解释了如何处理默认值、必传参数、布尔标志、多值等。例如，你应该对命令的主要对象或动作使用位置参数，对修改或可选参数使用标志。你还应该使用长短两种形式的标志（如-v或-verbose），并遵循常见的命名模式（如&#8211;help或&#8211;version）。</p>
<ul>
<li>配置</li>
</ul>
<p>这部分介绍了如何使用配置文件和环境变量来为你的CLI程序存储持久的设置。它还解释了如何处理配置选项的优先级、验证、文档等。例如，你应该使用配置文件来处理用户很少改变的设置，或者是针对某个项目或环境的设置。对于特定于环境或会话的设置（如凭证或路径），你也应该使用环境变量。</p>
<ul>
<li>输出</li>
</ul>
<p>这部分介绍了如何格式化和展示你的CLI程序的输出。它还解释了如何处理输出verbose级别、进度指示器、颜色、表格等。例如，你应该使用标准输出（stdout）进行正常的输出，这样输出的信息可以通过管道输送到其他程序或文件。你还应该使用标准错误（stderr）来处理不属于正常输出流的错误或警告。</p>
<ul>
<li>错误</li>
</ul>
<p>这部分介绍了如何在你的CLI程序中优雅地处理错误。它还解释了如何使用退出状态码、错误信息、堆栈跟踪等。例如，你应该使用表明错误类型的退出代码（如0代表成功，1代表一般错误）。你还应该使用简洁明了的错误信息，解释出错的原因以及如何解决。</p>
<ul>
<li>子命令</li>
</ul>
<p>这部分介绍了当CLI程序有多种操作或操作模式时，如何在CLI程序中使用子命令。它还解释了如何分层构建子命令，组织帮助文本，以及处理常见的子命令（如help或version）。例如，当你的程序有不同的功能，需要不同的参数或标志时（如git clone或git commit），你应该使用子命令。你还应该提供一个默认的子命令，或者在没有给出子命令时提供一个可用的子命令列表。</p>
<p>业界有许多精心设计的CLI工具的例子，它们都遵循cli准则，大家可以通过使用来深刻体会一下这些准则。下面是一些这样的CLI工具的例子：</p>
<ul>
<li>
<p>httpie：一个命令行HTTP客户端，具有直观的UI，支持JSON，语法高亮，类似wget的下载，插件等功能。例如，Httpie使用清晰简洁的语法进行HTTP请求，支持多种输出格式和颜色，优雅地处理错误并提供有用的文档。</p>
</li>
<li>
<p>git：一个分布式的版本控制系统，让你管理你的源代码并与其他开发者合作。例如，Git使用子命令进行不同的操作（如git clone或git commit），遵循通用的标志（如-v或-verbose），提供有用的反馈和建议（如git status或git help），并支持配置文件和环境变量。</p>
</li>
<li>
<p>npm：一个JavaScript的包管理器，让你为你的项目安装和管理依赖性。例如，NPM使用一个简单的命令结构（npm <command> [args]），提供一个简洁的初始帮助信息，有更详细的选项（npm help npm），支持标签完成和合理的默认值，并允许你通过配置文件（.npmrc）自定义设置。</p>
</li>
</ul>
<h2>八. 小结</h2>
<p>在这篇文章中，我们系统说明了如何编写出遵循命令行接口指南的Go CLI程序。</p>
<p>你学习了如何设置Go环境、设计命令行接口、处理错误和信号、编写文档、使用各种工具和软件包测试和发布程序。你还看到了一些代码和配置文件的例子。通过遵循这些准则和最佳实践，你可以创建一个用户友好、健壮和可靠的CLI程序。</p>
<p>最后我们回顾了clig.dev的指南要点，希望你能更深刻理解这些要点的含义。</p>
<p>我希望你喜欢这篇文章并认为它很有用。如果你有任何问题或反馈，请随时联系我。编码愉快！</p>
<blockquote>
<p>注：本文系与New Bing Chat联合完成，旨在验证如何基于AIGC能力构思和编写长篇文章。文章内容的正确性经过笔者全面审校，可放心阅读。</p>
</blockquote>
<hr />
<p><a href="https://wx.zsxq.com/dweb2/index/group/51284458844544">“Gopher部落”知识星球</a>旨在打造一个精品Go学习和进阶社群！高品质首发Go技术文章，“三天”首发阅读权，每年两期Go语言发展现状分析，每天提前1小时阅读到新鲜的Gopher日报，网课、技术专栏、图书内容前瞻，六小时内必答保证等满足你关于Go语言生态的所有需求！2023年，Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码，关注代码质量并深入理解Go核心技术，并继续加强与星友的互动。欢迎大家加入！</p>
<p><img src="http://image.tonybai.com/img/tonybai/gopher-tribe-zsxq-small-card.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/go-programming-from-beginner-to-master-qr.png" alt="img{512x368}" /></p>
<p><img src="http://image.tonybai.com/img/tonybai/go-first-course-banner.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/imooc-go-column-pgo-with-qr.jpg" alt="img{512x368}" /></p>
<p>著名云主机服务厂商DigitalOcean发布最新的主机计划，入门级Droplet配置升级为：1 core CPU、1G内存、25G高速SSD，价格5$/月。有使用DigitalOcean需求的朋友，可以打开这个<a href="https://m.do.co/c/bff6eed92687">链接地址</a>：https://m.do.co/c/bff6eed92687 开启你的DO主机之路。</p>
<p>Gopher Daily(Gopher每日新闻)归档仓库 &#8211; https://github.com/bigwhite/gopherdaily</p>
<p>我的联系方式：</p>
<ul>
<li>微博(暂不可用)：https://weibo.com/bigwhite20xx</li>
<li>微博2：https://weibo.com/u/6484441286</li>
<li>博客：tonybai.com</li>
<li>github: https://github.com/bigwhite</li>
</ul>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。</p>
<p style='text-align:left'>&copy; 2023, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2023/03/25/the-guide-of-developing-cli-program-in-go/feed/</wfw:commentRss>
		<slash:comments>3</slash:comments>
		</item>
		<item>
		<title>Go社区主流Kafka客户端简要对比</title>
		<link>https://tonybai.com/2022/03/28/the-comparison-of-the-go-community-leading-kakfa-clients/</link>
		<comments>https://tonybai.com/2022/03/28/the-comparison-of-the-go-community-leading-kakfa-clients/#comments</comments>
		<pubDate>Sun, 27 Mar 2022 23:24:13 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[Apache]]></category>
		<category><![CDATA[benchmark]]></category>
		<category><![CDATA[Block]]></category>
		<category><![CDATA[Cgo]]></category>
		<category><![CDATA[confluent]]></category>
		<category><![CDATA[confluent-kafka-go]]></category>
		<category><![CDATA[docker-compose]]></category>
		<category><![CDATA[EFK]]></category>
		<category><![CDATA[elastic]]></category>
		<category><![CDATA[ELK]]></category>
		<category><![CDATA[encoder]]></category>
		<category><![CDATA[fallback]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[goroutine]]></category>
		<category><![CDATA[Go语言精进之路]]></category>
		<category><![CDATA[Java]]></category>
		<category><![CDATA[Kafka]]></category>
		<category><![CDATA[kafka-go]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[ldd]]></category>
		<category><![CDATA[levelenabler]]></category>
		<category><![CDATA[libc]]></category>
		<category><![CDATA[librdkafka]]></category>
		<category><![CDATA[log]]></category>
		<category><![CDATA[logback]]></category>
		<category><![CDATA[Logstash]]></category>
		<category><![CDATA[Mock]]></category>
		<category><![CDATA[pulsar]]></category>
		<category><![CDATA[sarama]]></category>
		<category><![CDATA[segmentio]]></category>
		<category><![CDATA[Shopify]]></category>
		<category><![CDATA[Test]]></category>
		<category><![CDATA[Testing]]></category>
		<category><![CDATA[Thoughtworks]]></category>
		<category><![CDATA[writesyncer]]></category>
		<category><![CDATA[yaml]]></category>
		<category><![CDATA[zap]]></category>
		<category><![CDATA[zapcore]]></category>
		<category><![CDATA[动态共享库]]></category>
		<category><![CDATA[容器]]></category>
		<category><![CDATA[技术雷达]]></category>
		<category><![CDATA[测试]]></category>
		<category><![CDATA[阻塞]]></category>
		<category><![CDATA[静态编译]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=3487</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2022/03/28/the-comparison-of-the-go-community-leading-kakfa-clients 一. 背景 众所周知，Kafka是Apache开源基金会下的明星级开源项目，作为一个开源的分布式事件流平台，它被成千上万的公司用于高性能数据管道、流分析、数据集成和关键任务应用。在国内，无论大厂小厂，无论是自己部署还是用像阿里云提供的Kafka云服务，很多互联网应用已经离不开Kafka了。 互联网不拘泥于某种编程语言，但很多人不喜欢Kafka是由Scala/Java开发的。尤其是对于那些对某种语言有着“宗教般”虔诚、有着“手里拿着锤子，眼中满世界都是钉子”的程序员来说，总是有想重写Kafka的冲动。但就像很多新语言的拥趸想重写Kubernetes一样，Kafka已经建立起了巨大的起步和生态优势，短期很难建立起同样规格的巨型项目和对应的生态了(近两年同样火热的类Kafka的Apache pulsar创建时间与Kafka是先后脚的，只是纳入Apache基金会托管的时间较晚)。 Kafka生态很强大，各种编程语言都有对应的Kafka client。Kafka背后的那个公司confluent.inc也维护了各大主流语言的client： 其他主流语言的开发人员只需要利用好这些client端，做好与Kafka集群的连接就好了。好了做了这么多铺垫，下面说说为啥要写下这篇文章。 目前业务线生产环境的日志方案是这样的： 从图中我们看到：业务系统将日志写入Kafka，然后通过logstash工具消费日志并汇聚到后面的Elastic Search Cluster中供查询使用。 业务系统主要是由Java实现的，考虑到Kafka写失败的情况，为了防止log阻塞业务流程，业务系统使用了支持fallback appender的logback进行日志写入：这样当Kafka写入失败时，日志还可以写入备用的文件中，尽可能保证日志不丢失。 考虑到复用已有的IT设施与方案，我们用Go实现的新系统也向这种不落盘的log汇聚方案靠拢，这就要求我们的logger也要支持向Kafka写入并且支持fallback机制。 我们的log包是基于uber zap封装而来的，uber的zap日志包是目前Go社区使用最为广泛的、高性能的log包之一，第25期thoughtworks技术雷达也将zap列为试验阶段的工具推荐给大家，并且thoughtworks团队已经在大规模使用它： 不过，zap原生不支持写Kafka，但zap是可扩展的，我们需要为其增加写Kafka的扩展功能。而要写Kakfa，我们就离不开Kakfa Client包。目前Go社区主流的Kafka client有Shopify的sarama、Kafka背后公司confluent.inc维护的confluent-kafka-go以及segmentio/kafka-go。 在这篇文章中，我就根据我的使用历程逐一说说我对这三个客户端的使用感受。 下面，我们首先先来看看star最多的Shopify/sarama。 二. Shopify/sarama：星多不一定代表优秀 目前在Go社区星星最多，应用最广的Kafka client包是Shopify的sarama。Shopify是一家国外的电商平台，我总是混淆Shopify、Shopee(虾皮)以及传闻中要赞助巴萨的Spotify(瑞典流媒体音乐平台)，傻傻分不清^_^。 下面我就基于sarama演示一下如何扩展zap，让其支持写kafka。在《一文告诉你如何用好uber开源的zap日志库》一文中，我介绍过zap建构在zapcore之上，而zapcore由Encoder、WriteSyncer和LevelEnabler三部分组成，对于我们这个写Kafka的功能需求来说，我们只需要定义一个给一个WriteSyncer接口的实现，即可组装成一个支持向Kafka写入的logger。 我们自顶向下先来看看创建logger的函数： // https://github.com/bigwhite/experiments/blob/master/kafka-clients/zapkafka/log.go type Logger struct { l *zap.Logger // zap ensure that zap.Logger is safe for concurrent use cfg zap.Config level zap.AtomicLevel } func [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/the-comparison-of-the-go-community-leading-kakfa-clients-1.png" alt="" /></p>
<p><a href="https://tonybai.com/2022/03/28/the-comparison-of-the-go-community-leading-kakfa-clients">本文永久链接</a> &#8211; https://tonybai.com/2022/03/28/the-comparison-of-the-go-community-leading-kakfa-clients</p>
<h3>一. 背景</h3>
<p>众所周知，<a href="https://kafka.apache.org">Kafka</a>是Apache开源基金会下的明星级开源项目，作为一个开源的分布式事件流平台，它被成千上万的公司用于高性能数据管道、流分析、数据集成和关键任务应用。在国内，无论大厂小厂，无论是自己部署还是用像阿里云提供的Kafka云服务，很多互联网应用已经离不开Kafka了。</p>
<blockquote>
<p>互联网不拘泥于某种编程语言，但很多人不喜欢Kafka是由Scala/Java开发的。尤其是对于那些对某种语言有着“宗教般”虔诚、有着“手里拿着锤子，眼中满世界都是钉子”的程序员来说，总是有想重写Kafka的冲动。但就像很多新语言的拥趸想重写Kubernetes一样，Kafka已经建立起了巨大的起步和生态优势，短期很难建立起同样规格的巨型项目和对应的生态了(近两年同样火热的类Kafka的<a href="https://github.com/apache/pulsar">Apache pulsar</a>创建时间与Kafka是先后脚的，只是纳入Apache基金会托管的时间较晚)。</p>
</blockquote>
<p>Kafka生态很强大，各种编程语言都有对应的Kafka client。Kafka背后的那个公司<a href="https://confluent.io/">confluent.inc</a>也维护了各大主流语言的client：</p>
<p><img src="https://tonybai.com/wp-content/uploads/the-comparison-of-the-go-community-leading-kakfa-clients-2.png" alt="" /></p>
<p>其他主流语言的开发人员只需要利用好这些client端，做好与Kafka集群的连接就好了。好了做了这么多铺垫，下面说说为啥要写下这篇文章。</p>
<p>目前业务线生产环境的日志方案是这样的：</p>
<p><img src="https://tonybai.com/wp-content/uploads/the-comparison-of-the-go-community-leading-kakfa-clients-3.png" alt="" /></p>
<p>从图中我们看到：<strong>业务系统将日志写入Kafka，然后通过logstash工具消费日志并汇聚到后面的<a href="https://tonybai.com/2017/03/03/implement-kubernetes-cluster-level-logging-with-fluentd-and-elasticsearch-stack">Elastic Search Cluster</a>中供查询使用</strong>。 业务系统主要是由Java实现的，考虑到Kafka写失败的情况，为了防止log阻塞业务流程，业务系统使用了支持fallback appender的<a href="https://logback.qos.ch/">logback</a>进行日志写入：<strong>这样当Kafka写入失败时，日志还可以写入备用的文件中，尽可能保证日志不丢失</strong>。</p>
<p>考虑到复用已有的IT设施与方案，我们用Go实现的新系统也向这种不落盘的log汇聚方案靠拢，这就要求我们的logger也要支持向Kafka写入并且支持fallback机制。</p>
<p>我们的log包是<a href="https://mp.weixin.qq.com/s/cU5y465F7bhzVk6cHp0qVA">基于uber zap封装而来的</a>，<a href="https://github.com/uber-go/zap">uber的zap日志包</a>是目前Go社区使用最为广泛的、高性能的log包之一，第25期<a href="https://www.thoughtworks.com/zh-cn/radar">thoughtworks技术雷达</a>也将zap列为试验阶段的工具推荐给大家，并且thoughtworks团队已经在大规模使用它：</p>
<p><img src="https://tonybai.com/wp-content/uploads/the-comparison-of-the-go-community-leading-kakfa-clients-4.png" alt="" /></p>
<p>不过，zap原生不支持写Kafka，但zap是可扩展的，我们需要为其增加写Kafka的扩展功能。而要写Kakfa，我们就离不开Kakfa Client包。目前Go社区主流的Kafka client有<a href="https://github.com/Shopify/sarama">Shopify的sarama</a>、Kafka背后公司confluent.inc维护的<a href="https://github.com/confluentinc/confluent-kafka-go">confluent-kafka-go</a>以及<a href="https://github.com/segmentio/kafka-go/">segmentio/kafka-go</a>。</p>
<p>在这篇文章中，我就根据我的使用历程逐一说说我对这三个客户端的使用感受。</p>
<p>下面，我们首先先来看看star最多的Shopify/sarama。</p>
<h3>二. Shopify/sarama：星多不一定代表优秀</h3>
<p>目前在Go社区星星最多，应用最广的Kafka client包是Shopify的sarama。Shopify是一家国外的电商平台，我总是混淆Shopify、Shopee(虾皮)以及传闻中要赞助巴萨的Spotify(瑞典流媒体音乐平台)，傻傻分不清^_^。</p>
<p>下面我就基于sarama演示一下如何扩展zap，让其支持写kafka。在<a href="https://tonybai.com/2021/07/14/uber-zap-advanced-usage">《一文告诉你如何用好uber开源的zap日志库》</a>一文中，我介绍过zap建构在zapcore之上，而zapcore由Encoder、WriteSyncer和LevelEnabler三部分组成，对于我们这个写Kafka的功能需求来说，我们只需要<strong>定义一个给一个WriteSyncer接口的实现，即可组装成一个支持向Kafka写入的logger</strong>。</p>
<p>我们自顶向下先来看看创建logger的函数：</p>
<pre><code>// https://github.com/bigwhite/experiments/blob/master/kafka-clients/zapkafka/log.go

type Logger struct {
    l     *zap.Logger // zap ensure that zap.Logger is safe for concurrent use
    cfg   zap.Config
    level zap.AtomicLevel
}

func (l *Logger) Info(msg string, fields ...zap.Field) {
    l.l.Info(msg, fields...)
}

func New(writer io.Writer, level int8, opts ...zap.Option) *Logger {
    if writer == nil {
        panic("the writer is nil")
    }
    atomicLevel := zap.NewAtomicLevelAt(zapcore.Level(level))

    logger := &amp;Logger{
        cfg:   zap.NewProductionConfig(),
        level: atomicLevel,
    }

    logger.cfg.EncoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
        enc.AppendString(t.Format(time.RFC3339)) // 2021-11-19 10:11:30.777
    }
    logger.cfg.EncoderConfig.TimeKey = "logtime"

    core := zapcore.NewCore(
        zapcore.NewJSONEncoder(logger.cfg.EncoderConfig),
        zapcore.AddSync(writer),
        atomicLevel,
    )
    logger.l = zap.New(core, opts...)
    return logger
}

// SetLevel alters the logging level on runtime
// it is concurrent-safe
func (l *Logger) SetLevel(level int8) error {
    l.level.SetLevel(zapcore.Level(level))
    return nil
}
</code></pre>
<p>这段代码中没有与kafka client相关的内容，New函数用来创建一个&#42;Logger实例，它接受的第一个参数为io.Writer接口类型，用于指示日志的写入位置。这里要注意一点的是：<strong>我们使用zap.AtomicLevel类型存储logger的level信息，基于zap.AtomicLevel的level支持热更新，我们可以在程序运行时动态修改logger的log level</strong>。这个也是在<a href="https://tonybai.com/2021/07/14/uber-zap-advanced-usage">《一文告诉你如何用好uber开源的zap日志库》</a>遗留问题的答案。</p>
<p>接下来，我们就基于sarama的AsyncProducer来实现一个满足zapcore.WriteSyncer接口的类型：</p>
<pre><code>// https://github.com/bigwhite/experiments/blob/master/kafka-clients/zapkafka/kafka_syncer.go

type kafkaWriteSyncer struct {
    topic          string
    producer       sarama.AsyncProducer
    fallbackSyncer zapcore.WriteSyncer
}

func NewKafkaAsyncProducer(addrs []string) (sarama.AsyncProducer, error) {
    config := sarama.NewConfig()
    config.Producer.Return.Errors = true
    return sarama.NewAsyncProducer(addrs, config)
}

func NewKafkaSyncer(producer sarama.AsyncProducer, topic string, fallbackWs zapcore.WriteSyncer) zapcore.WriteSyncer {
    w := &amp;kafkaWriteSyncer{
        producer:       producer,
        topic:          topic,
        fallbackSyncer: zapcore.AddSync(fallbackWs),
    }

    go func() {
        for e := range producer.Errors() {
            val, err := e.Msg.Value.Encode()
            if err != nil {
                continue
            }

            fallbackWs.Write(val)
        }
    }()

    return w
}
</code></pre>
<p>NewKafkaSyncer是创建zapcore.WriteSyncer的那个函数，它的第一个参数使用了sarama.AsyncProducer接口类型，目的是为了可以利用<a href="https://github.com/Shopify/sarama/tree/main/mocks">sarama提供的mock测试包</a>。最后一个参数为fallback时使用的WriteSyncer参数。</p>
<p>NewKafkaAsyncProducer函数是用于方便用户快速创建sarama.AsyncProducer的，其中的config使用的是默认的config值。在config默认值中，Return.Successes的默认值都false，即表示客户端不关心向Kafka写入消息的成功状态，我们也无需单独建立一个goroutine来消费AsyncProducer.Successes()。但我们需要关注写入失败的消息，因此我们将Return.Errors置为true的同时在NewKafkaSyncer中启动了一个goroutine专门处理写入失败的日志数据，将这些数据写入fallback syncer中。</p>
<p>接下来，我们看看kafkaWriteSyncer的Write与Sync方法：</p>
<pre><code>// https://github.com/bigwhite/experiments/blob/master/kafka-clients/zapkafka/kafka_syncer.go

func (ws *kafkaWriteSyncer) Write(b []byte) (n int, err error) {
    b1 := make([]byte, len(b))
    copy(b1, b) // b is reused, we must pass its copy b1 to sarama
    msg := &amp;sarama.ProducerMessage{
        Topic: ws.topic,
        Value: sarama.ByteEncoder(b1),
    }

    select {
    case ws.producer.Input() &lt;- msg:
    default:
        // if producer block on input channel, write log entry to default fallbackSyncer
        return ws.fallbackSyncer.Write(b1)
    }
    return len(b1), nil
}

func (ws *kafkaWriteSyncer) Sync() error {
    ws.producer.AsyncClose()
    return ws.fallbackSyncer.Sync()
}
</code></pre>
<blockquote>
<p>注意：上面代码中的b会被zap重用，因此我们在扔给sarama channel之前需要将b copy一份，将副本发送给sarama。</p>
</blockquote>
<p>从上面代码看，这里我们将要写入的数据包装成一个sarama.ProducerMessage，然后发送到producer的Input channel中。这里有一个特殊处理，那就是当如果msg阻塞在Input channel上时，我们将日志写入fallbackSyncer。这种情况是出于何种考虑呢？这主要是因为基于sarama v1.30.0版本的kafka logger在我们的验证环境下出现过hang住的情况，当时的网络可能出现过波动，导致logger与kafka之间的连接出现过异常，我们初步怀疑就是这个位置阻塞，导致业务被阻塞住了。在<a href="https://github.com/Shopify/sarama/pull/2133">sarama v1.32.0版本中有一个fix</a>，和我们这个hang的现象很类似。</p>
<p>但这么做也有一个严重的问题，那就是在压测中，我们发现大量日志都无法写入到kafka，而是都写到了fallback syncer中。究其原因，我们在sarama的async_producer.go中看到：input channel是一个unbuffered channel，而从input channel读取消息的dispatcher goroutine也仅仅有一个，考虑到goroutine的调度，大量日志写入fallback syncer就不足为奇了：</p>
<pre><code>// github.com/Shopify/sarama@v1.32.0/async_producer.go
func newAsyncProducer(client Client) (AsyncProducer, error) {
    // Check that we are not dealing with a closed Client before processing any other arguments
    if client.Closed() {
        return nil, ErrClosedClient
    }

    txnmgr, err := newTransactionManager(client.Config(), client)
    if err != nil {
        return nil, err
    }

    p := &amp;asyncProducer{
        client:     client,
        conf:       client.Config(),
        errors:     make(chan *ProducerError),
        input:      make(chan *ProducerMessage), // 笔者注：这是一个unbuffer channel
        successes:  make(chan *ProducerMessage),
        retries:    make(chan *ProducerMessage),
        brokers:    make(map[*Broker]*brokerProducer),
        brokerRefs: make(map[*brokerProducer]int),
        txnmgr:     txnmgr,
    }
    ... ...
}
</code></pre>
<p>有人说这里可以加定时器(Timer)做超时，要知道日志都是在程序执行的关键路径上，每写一条log就启动一个Timer感觉太耗了(即便是Reset重用Timer)。<strong>如果sarama在任何时候都不会hang住input channel，那么在Write方法中我们还是不要使用select-default这样的trick</strong>。</p>
<p>sarama的一个不错的地方是提供了<a href="https://github.com/Shopify/sarama/tree/main/mocks">mocks测试工具包</a>，该包既可用于sarama的自测，也可以用作依赖sarama的go包的自测，以上面的实现为例，我们可以编写基于mocks测试包的一些test：</p>
<pre><code>// https://github.com/bigwhite/experiments/blob/master/kafka-clients/zapkafka/log_test.go

func TestWriteFailWithKafkaSyncer(t *testing.T) {
    config := sarama.NewConfig()
    p := mocks.NewAsyncProducer(t, config)

    var buf = make([]byte, 0, 256)
    w := bytes.NewBuffer(buf)
    w.Write([]byte("hello"))
    logger := New(NewKafkaSyncer(p, "test", NewFileSyncer(w)), 0)

    p.ExpectInputAndFail(errors.New("produce error"))
    p.ExpectInputAndFail(errors.New("produce error"))

    // all below will be written to the fallback sycner
    logger.Info("demo1", zap.String("status", "ok")) // write to the kafka syncer
    logger.Info("demo2", zap.String("status", "ok")) // write to the kafka syncer

    // make sure the goroutine which handles the error writes the log to the fallback syncer
    time.Sleep(2 * time.Second)

    s := string(w.Bytes())
    if !strings.Contains(s, "demo1") {
        t.Errorf("want true, actual false")
    }
    if !strings.Contains(s, "demo2") {
        t.Errorf("want true, actual false")
    }

    if err := p.Close(); err != nil {
        t.Error(err)
    }
}
</code></pre>
<p>测试通过mocks.NewAsyncProducer返回满足sarama.AsyncProducer接口的实现。然后设置expect，针对每条消息都要设置expect，这里写入两条日志，所以设置了两次。注意：<strong>由于我们是在一个单独的goroutine中处理的Errors channel，因此这里存在一些竞态条件</strong>。在并发程序中，Fallback syncer也一定要支持并发写，zapcore提供了zapcore.Lock可以用于将一个普通的zapcore.WriteSyncer包装成并发安全的WriteSyncer。</p>
<p>不过，使用sarama的过程中还遇到过一个“严重”的问题，那就是有些时候数据并没有完全写入到kafka。我们去掉针对input channel的select-default操作，然后创建一个concurrent-write小程序，用于并发的向kafka写入log：</p>
<pre><code>// https://github.com/bigwhite/experiments/blob/master/kafka-clients/zapkafka/cmd/concurrent_write/main.go

func SaramaProducer() {
    p, err := log.NewKafkaAsyncProducer([]string{"localhost:29092"})
    if err != nil {
        panic(err)
    }
    logger := log.New(log.NewKafkaSyncer(p, "test", zapcore.AddSync(os.Stderr)), int8(0))
    var wg sync.WaitGroup
    var cnt int64

    for j := 0; j &lt; 10; j++ {
        wg.Add(1)
        go func(j int) {
            var value string
            for i := 0; i &lt; 10000; i++ {
                now := time.Now()
                value = fmt.Sprintf("%02d-%04d-%s", j, i, now.Format("15:04:05"))
                logger.Info("log message:", zap.String("value", value))
                atomic.AddInt64(&amp;cnt, 1)
            }
            wg.Done()
        }(j)
    }

    wg.Wait()
    logger.Sync()
    println("cnt =", atomic.LoadInt64(&amp;cnt))
    time.Sleep(10 * time.Second)
}

func main() {
    SaramaProducer()
}
</code></pre>
<p>我们用kafka官方提供的<a href="https://developer.confluent.io/quickstart/kafka-docker/">docker-compose.yml</a>在本地启动一个kafka服务：</p>
<pre><code>$cd benchmark
$docker-compose up -d
</code></pre>
<p>然后我们使用kafka容器中自带的consumer工具从名为test的topic中消费数据，消费的数据重定向到1.log中：</p>
<pre><code>$docker exec benchmark_kafka_1 /bin/kafka-console-consumer --bootstrap-server localhost:9092 --topic test --from-beginning &gt; 1.log 2&gt;&amp;1
</code></pre>
<p>然后我们运行concurrent_write：</p>
<pre><code>$ make
$./concurrent_write &gt; 1.log 2&gt;&amp;1
</code></pre>
<p>concurrent_write程序启动了10个goroutine，每个goroutine向kafka写入1w条日志，多数情况下在benchmark目录下的1.log都能看到10w条日志记录，但在使用sarama v1.30.0版本时有些时候看到的是少于10w条的记录，至于那些“丢失”的记录则不知在何处了。使用sarama v1.32.0时，这种情况还尚未出现过。</p>
<p>好了，是时候看看下一个kafka client包了！</p>
<h3>三. confluent-kafka-go：需要开启cgo的包还是有点烦</h3>
<p><a href="https://github.com/confluentinc/confluent-kafka-go/">confluent-kafka-go包</a>是kafka背后的技术公司confluent.inc维护的Go客户端，也可以算是Kafka官方Go客户端了。不过这个包唯一的“问题”在于它是基于<a href="https://github.com/edenhill/librdkafka">kafka c/c++库librdkafka</a>构建而成，这意味着一旦你的Go程序依赖confluent-kafka-go，你就很难实现Go应用的静态编译，也无法实现<a href="https://tonybai.com/2014/10/20/cross-compilation-with-golang">跨平台编译</a>。由于所有业务系统都依赖log包，一旦依赖confluent-kafka-go只能动态链接，我们的构建工具链全需要更改，代价略大。</p>
<p>不过confluent-kafka-go使用起来也很简单，写入性能也不错，并且不存在前面sarama那样的“丢消息”的情况，下面是一个基于confluent-kafka-go的producer示例：</p>
<pre><code>// https://github.com/bigwhite/experiments/blob/master/kafka-clients/confluent-kafka-go-static-build/producer.go

func ReadConfig(configFile string) kafka.ConfigMap {
    m := make(map[string]kafka.ConfigValue)
    file, err := os.Open(configFile)
    if err != nil {
        fmt.Fprintf(os.Stderr, "Failed to open file: %s", err)
        os.Exit(1)
    }
    defer file.Close()

    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        line := strings.TrimSpace(scanner.Text())
        if !strings.HasPrefix(line, "#") &amp;&amp; len(line) != 0 {
            kv := strings.Split(line, "=")
            parameter := strings.TrimSpace(kv[0])
            value := strings.TrimSpace(kv[1])
            m[parameter] = value
        }
    }

    if err := scanner.Err(); err != nil {
        fmt.Printf("Failed to read file: %s", err)
        os.Exit(1)
    }
    return m
}

func main() {
    conf := ReadConfig("./producer.conf")

    topic := "test"
    p, err := kafka.NewProducer(&amp;conf)
    var mu sync.Mutex

    if err != nil {
        fmt.Printf("Failed to create producer: %s", err)
        os.Exit(1)
    }
    var wg sync.WaitGroup
    var cnt int64

    // Go-routine to handle message delivery reports and
    // possibly other event types (errors, stats, etc)
    go func() {
        for e := range p.Events() {
            switch ev := e.(type) {
            case *kafka.Message:
                if ev.TopicPartition.Error != nil {
                    fmt.Printf("Failed to deliver message: %v\n", ev.TopicPartition)
                } else {
                    fmt.Printf("Produced event to topic %s: key = %-10s value = %s\n",
                        *ev.TopicPartition.Topic, string(ev.Key), string(ev.Value))
                }
            }
        }
    }()

    for j := 0; j &lt; 10; j++ {
        wg.Add(1)
        go func(j int) {
            var value string
            for i := 0; i &lt; 10000; i++ {
                key := ""
                now := time.Now()
                value = fmt.Sprintf("%02d-%04d-%s", j, i, now.Format("15:04:05"))
                mu.Lock()
                p.Produce(&amp;kafka.Message{
                    TopicPartition: kafka.TopicPartition{Topic: &amp;topic, Partition: kafka.PartitionAny},
                    Key:            []byte(key),
                    Value:          []byte(value),
                }, nil)
                mu.Unlock()
                atomic.AddInt64(&amp;cnt, 1)
            }
            wg.Done()
        }(j)
    }

    wg.Wait()
    // Wait for all messages to be delivered
    time.Sleep(10 * time.Second)
    p.Close()
}
</code></pre>
<p>这里我们还是使用10个goroutine向kafka各写入1w消息，注意：默认使用kafka.NewProducer创建的Producer实例不是并发安全的，所以这里用一个sync.Mutex对其Produce调用进行同步管理。我们可以像sarama中的例子那样，在本地启动一个kafka服务，验证一下confluent-kafka-go的运行情况。</p>
<p>由于confluent-kafka-go包基于kafka c库而实现，所以我们没法关闭CGO，如果关闭CGO，将遇到下面编译问题：</p>
<pre><code>$CGO_ENABLED=0 go build
# producer
./producer.go:15:42: undefined: kafka.ConfigMap
./producer.go:17:29: undefined: kafka.ConfigValue
./producer.go:50:18: undefined: kafka.NewProducer
./producer.go:85:22: undefined: kafka.Message
./producer.go:86:28: undefined: kafka.TopicPartition
./producer.go:86:75: undefined: kafka.PartitionAny
</code></pre>
<p>因此，默认情况依赖confluent-kafka-go包的Go程序会采用动态链接，通过ldd查看编译后的程序结果如下(on CentOS)：</p>
<pre><code>$make build
$ldd producer
    linux-vdso.so.1 =&gt;  (0x00007ffcf87ec000)
    libm.so.6 =&gt; /lib64/libm.so.6 (0x00007f473d014000)
    libdl.so.2 =&gt; /lib64/libdl.so.2 (0x00007f473ce10000)
    libpthread.so.0 =&gt; /lib64/libpthread.so.0 (0x00007f473cbf4000)
    librt.so.1 =&gt; /lib64/librt.so.1 (0x00007f473c9ec000)
    libc.so.6 =&gt; /lib64/libc.so.6 (0x00007f473c61e000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f473d316000)
</code></pre>
<p>那么在CGO开启的情况下是否可以静态编译呢？理论上是可以的。这个在我的<a href="https://tonybai.com/2022/01/15/go-programming-from-beginners-to-masters-is-published">《Go语言精进之路》</a>中关于CGO一节有详细说明。</p>
<p>不过confluent-kafka-go包官方目前确认还不支持静态编译。我们来试试在CGO开启的情况下，对其进行静态编译：</p>
<pre><code>// on CentOS
$ go build -buildvcs=false -o producer-static -ldflags '-linkmode "external" -extldflags "-static"'
$ producer
/root/.bin/go1.18beta2/pkg/tool/linux_amd64/link: running gcc failed: exit status 1
/usr/bin/ld: 找不到 -lm
/usr/bin/ld: 找不到 -ldl
/usr/bin/ld: 找不到 -lpthread
/usr/bin/ld: 找不到 -lrt
/usr/bin/ld: 找不到 -lpthread
/usr/bin/ld: 找不到 -lc
collect2: 错误：ld 返回 1
</code></pre>
<p>静态链接会将confluent-kafka-go的c语言部分的符号进行静态链接，这些符号可能在libc、libpthread等c运行时库或系统库中，但默认情况下，CentOS是没有安装这些库的.a(archive)版本的。我们需要手动安装：</p>
<pre><code>$yum install glibc-static
</code></pre>
<p>安装后，我们再执行上面的静态编译命令：</p>
<pre><code>$go build -buildvcs=false -o producer-static -ldflags '-linkmode "external" -extldflags "-static"'
$ producer
/root/go/pkg/mod/github.com/confluentinc/confluent-kafka-go@v1.8.2/kafka/librdkafka_vendor/librdkafka_glibc_linux.a(rddl.o)：在函数‘rd_dl_open’中：
(.text+0x1d): 警告：Using 'dlopen' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
/root/go/pkg/mod/github.com/confluentinc/confluent-kafka-go@v1.8.2/kafka/librdkafka_vendor/librdkafka_glibc_linux.a(rdaddr.o)：在函数‘rd_getaddrinfo’中：
(.text+0x440): 警告：Using 'getaddrinfo' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
</code></pre>
<p>这回我们的静态编译成功了！</p>
<pre><code>$ ldd producer-static
    不是动态可执行文件
</code></pre>
<p>但有一些警告！我们先不理这些警告，试试编译出来的producer-static是否可用。使用docker-compose启动本地kafka服务，执行producer-static，我们发现程序可以正常将10w消息写入kafka，中间没有错误发生。至少在producer场景下，应用并没有执行包含dlopen、getaddrinfo的代码。</p>
<p>不过这不代表在其他场景下上面的静态编译方式没有问题，因此还是等官方方案出炉吧。或者使用builder容器构建你的基于confluent-kafka-go的程序。</p>
<p>我们继续往下看segmentio/kafka-go。</p>
<h3>四. segmentio/kafka-go：sync很慢，async很快！</h3>
<p>和sarama一样，segmentio/kafka-go也是一个纯go实现的kafka client，并且在很多公司的生产环境经历过考验，segmentio/kafka-go提供低级conn api和高级api(reader和writer)，以writer为例，相对低级api，它是并发safe的，还提供连接保持和重试，无需开发者自己实现，另外writer还支持sync和async写、带context.Context的超时写等。</p>
<p>不过Writer的sync模式写十分慢，1秒钟才几十条，但async模式就飞快了！</p>
<p>不过和confluent-kafka-go一样，segmentio/kafka-go也没有像sarama那样提供mock测试包，我们需要自己建立环境测试。kafka-go官方的建议时：<strong>在本地启动一个kafka服务，然后运行测试</strong>。在轻量级容器十分流行的时代，<strong>是否需要mock还真是一件值得思考的事情</strong>。</p>
<p>segmentio/kafka-go的使用体验非常棒，至今没有遇到过什么大问题，这里不举例了，例子见下面benchmark章节。</p>
<h3>五. 写入性能</h3>
<p>即便是简要对比，也不能少了benchmark。这里针对上面三个包分别建立了顺序benchmark和并发benchmark的测试用例：</p>
<pre><code>// https://github.com/bigwhite/experiments/blob/master/kafka-clients/benchmark/kafka_clients_test.go

var m = []byte("this is benchmark for three mainstream kafka client")

func BenchmarkSaramaAsync(b *testing.B) {
    b.ReportAllocs()
    config := sarama.NewConfig()
    producer, err := sarama.NewAsyncProducer([]string{"localhost:29092"}, config)
    if err != nil {
        panic(err)
    }

    message := &amp;sarama.ProducerMessage{Topic: "test", Value: sarama.ByteEncoder(m)}

    b.ResetTimer()
    for i := 0; i &lt; b.N; i++ {
        producer.Input() &lt;- message
    }
}

func BenchmarkSaramaAsyncInParalell(b *testing.B) {
    b.ReportAllocs()
    config := sarama.NewConfig()
    producer, err := sarama.NewAsyncProducer([]string{"localhost:29092"}, config)
    if err != nil {
        panic(err)
    }

    message := &amp;sarama.ProducerMessage{Topic: "test", Value: sarama.ByteEncoder(m)}

    b.ResetTimer()

    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            producer.Input() &lt;- message
        }
    })
}

func BenchmarkKafkaGoAsync(b *testing.B) {
    b.ReportAllocs()
    w := &amp;kafkago.Writer{
        Addr:     kafkago.TCP("localhost:29092"),
        Topic:    "test",
        Balancer: &amp;kafkago.LeastBytes{},
        Async:    true,
    }

    c := context.Background()
    b.ResetTimer()

    for i := 0; i &lt; b.N; i++ {
        w.WriteMessages(c, kafkago.Message{Value: []byte(m)})
    }
}

func BenchmarkKafkaGoAsyncInParalell(b *testing.B) {
    b.ReportAllocs()
    w := &amp;kafkago.Writer{
        Addr:     kafkago.TCP("localhost:29092"),
        Topic:    "test",
        Balancer: &amp;kafkago.LeastBytes{},
        Async:    true,
    }

    c := context.Background()
    b.ResetTimer()

    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            w.WriteMessages(c, kafkago.Message{Value: []byte(m)})
        }
    })
}

func ReadConfig(configFile string) ckafkago.ConfigMap {
    m := make(map[string]ckafkago.ConfigValue)

    file, err := os.Open(configFile)
    if err != nil {
        fmt.Fprintf(os.Stderr, "Failed to open file: %s", err)
        os.Exit(1)
    }
    defer file.Close()

    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        line := strings.TrimSpace(scanner.Text())
        if !strings.HasPrefix(line, "#") &amp;&amp; len(line) != 0 {
            kv := strings.Split(line, "=")
            parameter := strings.TrimSpace(kv[0])
            value := strings.TrimSpace(kv[1])
            m[parameter] = value
        }
    }

    if err := scanner.Err(); err != nil {
        fmt.Printf("Failed to read file: %s", err)
        os.Exit(1)
    }

    return m

}

func BenchmarkConfluentKafkaGoAsync(b *testing.B) {
    b.ReportAllocs()
    conf := ReadConfig("./confluent-kafka-go.conf")

    topic := "test"
    p, _ := ckafkago.NewProducer(&amp;conf)

    go func() {
        for _ = range p.Events() {
        }
    }()

    key := []byte("")
    b.ResetTimer()
    for i := 0; i &lt; b.N; i++ {
        p.Produce(&amp;ckafkago.Message{
            TopicPartition: ckafkago.TopicPartition{Topic: &amp;topic, Partition: ckafkago.PartitionAny},
            Key:            key,
            Value:          m,
        }, nil)
    }
}

func BenchmarkConfluentKafkaGoAsyncInParalell(b *testing.B) {
    b.ReportAllocs()
    conf := ReadConfig("./confluent-kafka-go.conf")

    topic := "test"
    p, _ := ckafkago.NewProducer(&amp;conf)

    go func() {
        for range p.Events() {
        }
    }()

    var mu sync.Mutex
    key := []byte("")
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu.Lock()
            p.Produce(&amp;ckafkago.Message{
                TopicPartition: ckafkago.TopicPartition{Topic: &amp;topic, Partition: ckafkago.PartitionAny},
                Key:            key,
                Value:          m,
            }, nil)
            mu.Unlock()
        }
    })
}
</code></pre>
<p>本地启动一个kafka服务，运行该benchmark：</p>
<pre><code>$go test -bench .
goos: linux
goarch: amd64
pkg: kafka_clients
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkSaramaAsync-4                            802070          2267 ns/op         294 B/op          1 allocs/op
BenchmarkSaramaAsyncInParalell-4                 1000000          1913 ns/op         294 B/op          1 allocs/op
BenchmarkKafkaGoAsync-4                          1000000          1208 ns/op         376 B/op          5 allocs/op
BenchmarkKafkaGoAsyncInParalell-4                1768538          703.4 ns/op        368 B/op          5 allocs/op
BenchmarkConfluentKafkaGoAsync-4                 1000000          3154 ns/op         389 B/op         10 allocs/op
BenchmarkConfluentKafkaGoAsyncInParalell-4        742476          1863 ns/op         390 B/op         10 allocs/op
</code></pre>
<p>我们看到，虽然sarama在内存分配上有优势，但综合性能上还是segmentio/kafka-go最优。</p>
<h3>六. 小结</h3>
<p>本文对比了Go社区的三个主流kafka客户端包：Shopify/sarama、confluent-kafka-go和segmentio/kafka-go。sarama应用最广，也是我研究时间最长的一个包，但坑也是最多的，放弃；confluent-kafka-go虽然是官方的，但是基于cgo，无奈放弃；最后，我们选择了segmentio/kafka-go，已经在线上运行了一段时间，至今尚未发现重大问题。</p>
<p>不过，本文的对比仅限于作为Producer这块的场景，是一个“不完全”的介绍。后续如有更多场景的实践经验，还会再补充。</p>
<p>本文中的源码可以在<a href="https://github.com/bigwhite/experiments/blob/master/kafka-clients">这里</a>下载。</p>
<hr />
<p><a href="https://mp.weixin.qq.com/s/jUqAL7hf2GmMun64BJufEA">“Gopher部落”知识星球</a>旨在打造一个精品Go学习和进阶社群！高品质首发Go技术文章，“三天”首发阅读权，每年两期Go语言发展现状分析，每天提前1小时阅读到新鲜的Gopher日报，网课、技术专栏、图书内容前瞻，六小时内必答保证等满足你关于Go语言生态的所有需求！2022年，Gopher部落全面改版，将持续分享Go语言与Go应用领域的知识、技巧与实践，并增加诸多互动形式。欢迎大家加入！</p>
<p><img src="http://image.tonybai.com/img/tonybai/gopher-tribe-zsxq-small-card.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/go-programming-from-beginner-to-master-qr.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/go-first-course-banner.png" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/imooc-go-column-pgo-with-qr.jpg" alt="img{512x368}" /><br />
<img src="http://image.tonybai.com/img/tonybai/imooc-k8s-practice-with-qr.jpg" alt="img{512x368}" /></p>
<p><a href="https://tonybai.com/">我爱发短信</a>：企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台，三网覆盖，不惧大并发接入，可定制扩展； 短信内容你来定，不再受约束, 接口丰富，支持长短信，签名可选。2020年4月8日，中国三大电信运营商联合发布《5G消息白皮书》，51短信平台也会全新升级到“51商用消息平台”，全面支持5G RCS消息。</p>
<p>著名云主机服务厂商DigitalOcean发布最新的主机计划，入门级Droplet配置升级为：1 core CPU、1G内存、25G高速SSD，价格5$/月。有使用DigitalOcean需求的朋友，可以打开这个<a href="https://m.do.co/c/bff6eed92687">链接地址</a>：https://m.do.co/c/bff6eed92687 开启你的DO主机之路。</p>
<p>Gopher Daily(Gopher每日新闻)归档仓库 &#8211; https://github.com/bigwhite/gopherdaily</p>
<p>我的联系方式：</p>
<ul>
<li>微博：https://weibo.com/bigwhite20xx</li>
<li>微信公众号：iamtonybai</li>
<li>博客：tonybai.com</li>
<li>github: https://github.com/bigwhite</li>
<li>“Gopher部落”知识星球：https://public.zsxq.com/groups/51284458844544</li>
</ul>
<p><img src="http://image.tonybai.com/img/tonybai/iamtonybai-wechat-qr.png" alt="" /></p>
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。</p>
<p style='text-align:left'>&copy; 2022 &#8211; 2023, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2022/03/28/the-comparison-of-the-go-community-leading-kakfa-clients/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>Go语言“十诫”[译]</title>
		<link>https://tonybai.com/2021/04/09/ten-commandments-of-go/</link>
		<comments>https://tonybai.com/2021/04/09/ten-commandments-of-go/#comments</comments>
		<pubDate>Fri, 09 Apr 2021 08:01:18 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[bytes]]></category>
		<category><![CDATA[bytes.Buffer]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[gofmt]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[goroutine]]></category>
		<category><![CDATA[handler]]></category>
		<category><![CDATA[http]]></category>
		<category><![CDATA[Interface]]></category>
		<category><![CDATA[IO]]></category>
		<category><![CDATA[io.Writer]]></category>
		<category><![CDATA[json]]></category>
		<category><![CDATA[leak]]></category>
		<category><![CDATA[Mock]]></category>
		<category><![CDATA[os.File]]></category>
		<category><![CDATA[panic]]></category>
		<category><![CDATA[recover]]></category>
		<category><![CDATA[Test]]></category>
		<category><![CDATA[Testing]]></category>
		<category><![CDATA[waitgroup]]></category>
		<category><![CDATA[显式]]></category>
		<category><![CDATA[测试]]></category>
		<category><![CDATA[社区]]></category>

		<guid isPermaLink="false">https://tonybai.com/?p=3163</guid>
		<description><![CDATA[本文永久链接 &#8211; https://tonybai.com/2021/04/09/ten-commandments-of-go 本文翻译自John Arundel的《Ten commandments of Go》。全文如下： 作为一名全职的Go语言作家和老师，我花了很多时间和学生们一起，帮助他们写出更清晰、更好、更有用的Go程序。我发现，我给他们的建议可以归纳总结为一套通用原则，在这里我将这些原则分享给大家。 1. 你应该是无聊的 Go社区喜欢共识(consensus)。比如：Go源代码有一个由gofmt强制执行的统一的代码格式规范。同样，无论你要解决什么问题，通常都有一个标准的、类似于Go行事风格的方法来解决。有时它是标准的方式，因为它是最好的方式，但通常它只是最好的方式，因为它是标准的方式。 要抵制住创意、时尚或（最糟糕的是）聪明的诱惑，这些不是Go的行事风格。Go行事风格的代码简单、无聊，通常相当啰嗦，而且最重要的是显式的风格(由于这个原因，有些人把Go称为面向显式(obviousness-oriented)风格的编程语言)。 当有疑问时，请遵循最小惊喜原则。争取做到一目了然。要直截了当，要简单，要显式，要无聊。 这并不是说在软件工程层面没有展示令人叹为观止的优雅和风格的空间了；当然有。但那是在设计层面上，而不是单个代码行。代码并不重要，它应该以被随时替换。重要的是程序。 2. 你应该以测试为先 在Go中，一个常见的错误是先写了一些函数(比如：GetDataFromAPI)，然后在考虑如何测试它时不知所措。函数通过网络进行了真正的API调用，它向终端打印东西，它写磁盘文件了，这是一个可怕的的不可测试性的坑。 不要先写那个函数，而是先写一个测试(比如：TestGetDataFromAPI)。如何写这样一个测试呢？它必须为函数的调用提供一个本地的TLS测试服务器，所以你需要一种方法来注入这种依赖。它要写数据到io.Writer，你同样需要为此注入一个模拟外部世界的本地依赖，比如：bytes.Buffer。 现在，当你开始编写GetDataFromAPI函数时，一切都将变得很容易了。它的所有依赖关系都被注入，所以它的业务逻辑与它与外部世界的交互和监听方式完全脱钩。 HTTP handler也是如此。一个HTTP handler的唯一工作是解析请求中的数据，将其传递给某个业务逻辑函数来计算结果，并将结果格式化到ResponseWriter。这几乎不需要测试，所以你的大部分测试将在业务逻辑函数本身，而不是handler。我们知道HTTP的工作原理。 3. 你应该测试行为，而不是函数 如果你想知道如何在不实际调用API的情况下测试这个函数，那么答案很简单：”不要测试这个函数”。 你需要测试的不是一些函数，而是一些行为。例如，一个可能是”给定一些用户输入，我可以正确地组合URL并以正确的参数调用API。” 另一个可能是”给定API返回的一些JSON数据，我可以正确地将其解包到某个Go结构体中。” 当你沿着这样的思路考量问题的解决方法的时候，写测试就容易多了：你可以想象一些这类函数，它们每个函数都会接受一些输入，并产生一些输出，并且很容易给它们编写单元测试。有些事情它们是不会做的，例如进行任何HTTP调用。 同样，当你试图实现”数据可以持久地存储在数据库中并从数据库中检索”这样的行为时，你可以将其分解成更小的、更可测试的行为。例如，”给定一个Go结构体，我可以正确地生成SQL查询，并将其内容存储到Postgres表中”，或者 “给定一个对象，我可以正确地将结果解析到Go结构体切片中”。不需要mock数据库，不需要真正的数据库！ 4. 你不应制造文书工作 所有的程序都会在某一点上涉及到一些繁琐的、不可避免的数据倒换重组活动；我们可以把所有这类活动归入文书工作的范畴。对程序员来说，唯一的问题是，这些文书工作在API边界的哪一边？ 如果是放在用户侧，那就意味着用户必须编写大量的代码来为你的库准备文书工作，然后再编写大量的代码来将结果解压成有用的格式。 相反(将文书工作放在API实现侧)，写零文书工作的库，可以在一行中调用： game.Run() 不要让用户调用一个构造函数来获取某个对象，然后再基于这个对象进行方法调用。那就是文书工作。只要让一切在他们直接调用时发生就可以了。如果有可配置的设置，请设置合理的默认值，这样用户根本不用考虑，除非他们因为某些原因需要覆盖默认值。功能选项(functional option)是一个很好的模式。 这是另一个先写测试的好理由，如果你写的API中创造了文书工作，那么在测试时你将不得不自己做所有的文书工作，以便使用你自己的库。如果这被证明是笨拙、啰嗦和耗时的，可以考虑将这些文书工作移到API边界内。 5. 你不应该杀死程序 你的库没有权利终止用户的程序。不要在你的包中调用像os.Exit、log.Fatal、panic这样的函数，这不是你能决定的。相反，如果你遇到了不可恢复(recover)的错误，将它们返回给调用者。 为什么不呢？因为它迫使任何想使用你的库的人去写代码，不管panic是否真的被触发。出于同样的原因，你永远不应该使用会引起panic的第三方库，因为一旦你用了，你就需要recover它们。 所以你千万不要显式调用(这些可以杀死程序的函数)，但是隐式调用呢？你所做的任何操作，在某些情况下可能会panic（比如：索引一个空的片断，写入一个空map，类型断言失败）都应该先检查一下是否正常，如果不正常就返回一个错误。 6. 你不要泄露资源 对于一个打算永远运行而不崩溃或出错的程序来说，对其的要求要比对单次命令行工具要严格一些。例如，想想太空探测器：在关键时刻意外重启制导系统，可能会让价值数十亿美元的飞行器驶向星系间的虚空。对于负责的软件工程师来说，这很可能会导致一场没有咖啡的面谈，让人有些不舒服。 我们不是都在为太空器写软件，但我们应该像太空工程师一样思考。自然，我们的程序应该永远不会崩溃（最坏的情况下，它们应该优雅地退化，并提出退出过程的详实信息），但它们也需要是可持续的。这意味着不能泄露内存、goroutines、文件句柄或任何其他稀缺资源。 每当你有一些可泄漏的资源时，当你知道你已经成功获得它的那一刻，你应该想着释放它。无论函数如何退出或何时退出，保证将其清理掉，我们可以用Go带给我们的礼物：defer。 任何时候启动一个goroutine，你都应该知道它是如何结束的。启动它的同一个函数应该负责停止它。使用waitgroups或者errgroups，并且总是向一个可能被取消的函数传递一个context.Context。 7. 你不应该限制用户的选择 我们如何编写友好、灵活、强大、易用的库呢？一种方法是避免不必要地限制用户对库的操作。一个常见的Gopherism(Go主义)是 “接受接口，返回结构”。但为什么这是个好建议呢？ [...]]]></description>
			<content:encoded><![CDATA[<p><img src="https://tonybai.com/wp-content/uploads/ten-commandments-of-go-1.png" alt="" /></p>
<p><a href="https://tonybai.com/2021/04/09/ten-commandments-of-go">本文永久链接</a> &#8211; https://tonybai.com/2021/04/09/ten-commandments-of-go</p>
<p>本文翻译自John Arundel的<a href="https://bitfieldconsulting.com/golang/commandments">《Ten commandments of Go》</a>。全文如下：</p>
<p>作为一名全职的Go语言<a href="https://bitfieldconsulting.com/books">作家</a>和<a href="https://bitfieldconsulting.com/golang/learn">老师</a>，我花了很多时间和学生们一起，帮助他们写出更清晰、更好、更有用的Go程序。我发现，我给他们的建议可以归纳总结为一套通用原则，在这里我将这些原则分享给大家。</p>
<h3>1. 你应该是无聊的</h3>
<p>Go社区喜欢共识(consensus)。比如：Go源代码有一个由gofmt强制执行的统一的代码格式规范。同样，无论你要解决什么问题，通常都有一个标准的、类似于Go行事风格的方法来解决。<strong>有时它是标准的方式，因为它是最好的方式，但通常它只是最好的方式，因为它是标准的方式</strong>。</p>
<p>要抵制住创意、时尚或（最糟糕的是）聪明的诱惑，这些不是Go的行事风格。Go行事风格的代码简单、无聊，通常相当啰嗦，而且最重要的是显式的风格(由于这个原因，有些人把Go称为面向显式(obviousness-oriented)风格的编程语言)。</p>
<p>当有疑问时，请遵循<a href="https://en.wikipedia.org/wiki/Principle_of_least_astonishment">最小惊喜原则</a>。争取做到<a href="https://www.youtube.com/watch?v=8TLiGHJTlig">一目了然</a>。要直截了当，要简单，要显式，要无聊。</p>
<p>这并不是说在软件工程层面没有展示令人叹为观止的优雅和风格的空间了；当然有。但那是在设计层面上，而不是单个代码行。代码并不重要，它应该以被随时替换。重要的是程序。</p>
<h3>2. 你应该以测试为先</h3>
<p>在Go中，一个常见的错误是先写了一些函数(比如：GetDataFromAPI)，然后在考虑如何测试它时不知所措。函数通过网络进行了真正的API调用，它向终端打印东西，它写磁盘文件了，这是一个可怕的的不可测试性的坑。</p>
<p>不要先写那个函数，而是先写一个测试(比如：TestGetDataFromAPI)。如何写这样一个测试呢？它必须为函数的调用提供一个本地的TLS测试服务器，所以你需要一种方法来注入这种依赖。它要写数据到io.Writer，你同样需要为此注入一个模拟外部世界的本地依赖，比如：bytes.Buffer。</p>
<p>现在，当你开始编写GetDataFromAPI函数时，一切都将变得很容易了。它的所有依赖关系都被注入，所以它的业务逻辑与它与外部世界的交互和监听方式完全脱钩。</p>
<p>HTTP handler也是如此。一个HTTP handler的唯一工作是解析请求中的数据，将其传递给某个业务逻辑函数来计算结果，并将结果格式化到ResponseWriter。这几乎不需要测试，所以你的大部分测试将在业务逻辑函数本身，而不是handler。我们知道HTTP的工作原理。</p>
<h3>3. 你应该测试行为，而不是函数</h3>
<p>如果你想知道如何在不实际调用API的情况下测试这个函数，那么答案很简单：”不要测试这个函数”。</p>
<p>你需要测试的不是一些函数，而是一些行为。例如，一个可能是”给定一些用户输入，我可以正确地组合URL并以正确的参数调用API。” 另一个可能是”给定API返回的一些JSON数据，我可以正确地将其解包到某个Go结构体中。”</p>
<p>当你沿着这样的思路考量问题的解决方法的时候，写测试就容易多了：你可以想象一些这类函数，它们每个函数都会接受一些输入，并产生一些输出，并且很容易给它们编写单元测试。有些事情它们是不会做的，例如进行任何HTTP调用。</p>
<p>同样，当你试图实现”数据可以持久地存储在数据库中并从数据库中检索”这样的行为时，你可以将其分解成更小的、更可测试的行为。例如，”给定一个Go结构体，我可以正确地生成SQL查询，并将其内容存储到Postgres表中”，或者 “给定一个对象，我可以正确地将结果解析到Go结构体切片中”。不需要mock数据库，不需要真正的数据库！</p>
<h3>4. 你不应制造文书工作</h3>
<p>所有的程序都会在某一点上涉及到一些繁琐的、不可避免的数据倒换重组活动；我们可以把所有这类活动归入文书工作的范畴。对程序员来说，唯一的问题是，这些文书工作在API边界的哪一边？</p>
<p>如果是放在用户侧，那就意味着用户必须编写大量的代码来为你的库准备文书工作，然后再编写大量的代码来将结果解压成有用的格式。</p>
<p>相反(将文书工作放在API实现侧)，写零文书工作的库，可以在一行中调用：</p>
<pre><code>game.Run()
</code></pre>
<p><strong>不要让用户调用一个构造函数来获取某个对象，然后再基于这个对象进行方法调用。那就是文书工作</strong>。只要让一切在他们直接调用时发生就可以了。如果有可配置的设置，请设置合理的默认值，这样用户根本不用考虑，除非他们因为某些原因需要覆盖默认值。<a href="https://www.imooc.com/read/87/article/2424">功能选项(functional option)</a>是一个很好的模式。</p>
<p>这是另一个先写测试的好理由，如果你写的API中创造了文书工作，那么在测试时你将不得不自己做所有的文书工作，以便使用你自己的库。如果这被证明是笨拙、啰嗦和耗时的，可以考虑将这些文书工作移到API边界内。</p>
<h3>5. 你不应该杀死程序</h3>
<p>你的库没有权利终止用户的程序。不要在你的包中调用像os.Exit、log.Fatal、panic这样的函数，这不是你能决定的。相反，如果你遇到了不可恢复(recover)的错误，将它们返回给调用者。</p>
<p>为什么不呢？因为它迫使任何想使用你的库的人去写代码，不管panic是否真的被触发。出于同样的原因，你永远不应该使用会引起panic的第三方库，因为一旦你用了，你就需要recover它们。</p>
<p>所以你千万不要显式调用(这些可以杀死程序的函数)，但是隐式调用呢？你所做的任何操作，在某些情况下可能会panic（比如：索引一个空的片断，写入一个空map，类型断言失败）都应该先检查一下是否正常，如果不正常就返回一个错误。</p>
<h3>6. 你不要泄露资源</h3>
<p>对于一个打算永远运行而不崩溃或出错的程序来说，对其的要求要比对单次命令行工具要严格一些。例如，想想太空探测器：在关键时刻意外重启制导系统，可能会让价值数十亿美元的飞行器驶向星系间的虚空。对于负责的软件工程师来说，这很可能会导致一场没有咖啡的面谈，让人有些不舒服。</p>
<p><strong>我们不是都在为太空器写软件，但我们应该像太空工程师一样思考</strong>。自然，我们的程序应该永远不会崩溃（最坏的情况下，它们应该优雅地退化，并提出退出过程的详实信息），但它们也需要是可持续的。这意味着不能泄露内存、goroutines、文件句柄或任何其他稀缺资源。</p>
<p>每当你有一些可泄漏的资源时，当你知道你已经成功获得它的那一刻，你应该想着释放它。无论函数如何退出或何时退出，保证将其清理掉，我们可以用<a href="https://www.imooc.com/read/87/article/2421">Go带给我们的礼物：defer</a>。</p>
<p>任何时候启动一个goroutine，你都应该知道它是如何结束的。启动它的同一个函数应该负责停止它。使用waitgroups或者errgroups，并且总是向一个可能被取消的函数传递一个context.Context。</p>
<h3>7. 你不应该限制用户的选择</h3>
<p>我们如何编写友好、灵活、强大、易用的库呢？一种方法是避免不必要地限制用户对库的操作。一个常见的Gopherism(Go主义)是 “接受接口，返回结构”。但为什么这是个好建议呢？</p>
<p>假设你有一个函数，接受类似于一个&#42;os.File的参数 ，并向其写入数据。也许被写入的东西是一个文件并不重要，具体来说，它只需要是一个 “你可以写入的东西”（这个想法由标准库接口，如io.Writer表达）。有很多这样的东西：网络连接、HTTP response writer、bytes.Buffer等等。</p>
<p>通过强迫用户传递给你一个文件，你限制了他们对你的库的使用。通过接受一个接口(如 io.Writer)来代替，你将打开新的可能性，包括尚未被创造的类型，后续它们仍然可以满足(接口) ，可以与你的代码io.Writer一起工作。</p>
<p>为什么要 “返回结构体”？好吧，假设你返回一些接口类型。这极大地限制了用户对该值的操作（他们能做的就是调用其上的方法）。即使他们事实上可以用底层的具体类型做他们需要做的事情，他们也必须先用类型断言来解包它。换句话说，这就是额外的文书工作(应该避免)。</p>
<p>另一种避免限制用户选择的方法是不要使用只有当前Go版本才有的功能。相反，考虑至少支持最近两个主要的Go版本：有些人不能立即升级。</p>
<h3>8. 你应该设定边界</h3>
<p>让每一个软件组件在自己的内部是完整的、有能力的；不要让它的内部关注点暴露出来，越过它的边界渗入到其他组件中。这一点对于与其他人的代码的边界来说，是双倍的。</p>
<p>例如，假设你的库调用了某个API。这个API会有自己的模式和自己的词汇，反映自己的关注点和自己的领域语言。</p>
<p>边界是那些与你的代码接触的点：例如，调用API并解析其响应的函数。我把它称为 “airlock “函数，因为它的工作部分是确保你的内部类型和关注点不会泄露出去，并防止外来数据泄露进来。</p>
<p>一旦你让一点外来数据在你的程序内部自由运行，它很快就会到处乱跑。你的其他包都需要导入这些外来类型，这很烦人，而且代码将会有一股糟糕的味道。</p>
<p>相反，你的airlock函数应该做两件事：它应该将外来数据转化为你自己的内部格式，而且应该确保数据是有效的。现在，你的所有其他代码只需要处理你的内部类型，它不需要担心数据是否会出错、丢失或不完整。</p>
<p>另一种执行良好边界的方法是始终检查错误。如果你不这样做，无效的数据可能会泄露进来。</p>
<h3>9. 你不应该在内部使用接口</h3>
<p>一个接口值说：”我不知道这个东西到底是什么，但也许我知道有些事情我可以用它来做。” 这在Go程序中是一种超级不方便的值，因为我们不能做任何没有被接口指定的事情。</p>
<p>对于空接口(interface{})来说，这也是双倍的，因为我们对它一无所知。因此，根据定义，如果你有一个空的接口值，你需要把它类型化为具体的东西才能使用它。</p>
<p>在处理任意数据（也就是在运行时类型或模式未知的数据）时，不得不使用它们是很常见的，比如无处不在的<a href="https://bitfieldconsulting.com/golang/map-string-interface">map[string]interface{}</a>。但是，我们应该尽快使用airlock将这一团无知转化为某种具体类型的有用的Go值。</p>
<p>特别是，不要用interface{}类型值来模拟泛型（<a href="https://bitfieldconsulting.com/golang/map-string-interface">Go有泛型</a>）。不要写一个函数，接受一些可以是七种具体类型之一的值，然后对其进行类型转换，为该类型找到合适的操作。相反，写七个函数，每个具体类型一个。</p>
<p>不要仅因为你可以在测试中注入mock，就创建一个公共的接口，这是一个错误。创建一个真正的用户在调用你的函数之前必须实现的接口，这违反了“无文书工作原则”。不要在一般情况下写mock；Go不适合这种风格的测试。(当Go中的某些东西很困难时，这通常是你做错事的标志。)</p>
<h3>10. 你不要盲目地遵从诫命，而要自己思考</h3>
<p>人们说：”告诉我们什么是最佳做法”，仿佛有一本小秘籍，里面有任何技术或组织问题的正确答案。(是有的，但不要说出去。我们不希望每个人都成为顾问)。</p>
<p>小心任何看似清楚、明确、简单地告诉你在某种情况下该怎么做的建议。它不会适用于每一种情况，在适用的地方，它都需要告诫，需要细微的差别，需要澄清。</p>
<p>每个人都希望得到的是不需要真正理解就能应用的建议。但这样的建议比它能带来的帮助更危险：它能让你走到桥的一半，然后你会发现桥是纸做的，而且刚开始下雨。</p>
<hr />
<p>非常感谢<a href="https://www.ardanlabs.com/">比尔-肯尼迪（Bill Kennedy）</a>和<a href="https://medium.com/@inanc">伊南克-古姆斯（Inanc Gumus）</a>对这篇文章的有益评论。</p>
<hr />
<p><a href="https://mp.weixin.qq.com/s/jUqAL7hf2GmMun64BJufEA">“Gopher部落”知识星球</a>正式转正（从试运营星球变成了正式星球）！“gopher部落”旨在打造一个精品Go学习和进阶社群！高品质首发Go技术文章，“三天”首发阅读权，每年两期Go语言发展现状分析，>每天提前1小时阅读到新鲜的Gopher日报，网课、技术专栏、图书内容前瞻，六小时内必答保证等满足你关于Go语言生态的所有需>求！部落目前虽小，但持续力很强。在2021年上半年，部落将策划两个专题系列分享，并且是部落独享哦：</p>
<ul>
<li>Go技术书籍的书摘和读书体会系列</li>
<li>Go与eBPF系列</li>
</ul>
<p>欢迎大家加入！</p>
<p><img src="http://image.tonybai.com/img/202103/gopher-tribe-zsxq-card.png" alt="" /></p>
<p>Go技术专栏“<a href="https://www.imooc.com/read/87">改善Go语⾔编程质量的50个有效实践</a>”正在慕课网火热热销中！本专栏主要满足广大gopher关于Go语言进阶的需求，围绕如何写出地道且高质量Go代码给出50条有效实践建议，上线后收到一致好评！欢迎大家订<br />
阅！</p>
<p><img src="https://tonybai.com/wp-content/uploads/imooc-go-column-pgo-with-qr.jpg" alt="img{512x368}" /></p>
<p>我的网课“<a href="https://coding.imooc.com/class/284.html">Kubernetes实战：高可用集群搭建、配置、运维与应用</a>”在慕课网热卖中，欢迎小伙伴们订阅学习！</p>
<p><img src="https://tonybai.com/wp-content/uploads/imooc-k8s-practice-with-qr.jpg" alt="img{512x368}" /></p>
<p><a href="https://tonybai.com/">我爱发短信</a>：企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台，三网覆盖，不惧大并发接入，可定制扩展； 短信内容你来定，不再受约束, 接口丰富，支持长短信，签名可选。2020年4月8日，中国三大电信运营商联合发布《5G消息白皮书》，51短信平台也会全新升级到“51商用消息平台”，全面支持5G RCS消息。</p>
<p>著名云主机服务厂商DigitalOcean发布最新的主机计划，入门级Droplet配置升级为：1 core CPU、1G内存、25G高速SSD，价格5$/月。有使用DigitalOcean需求的朋友，可以打开这个<a href="https://m.do.co/c/bff6eed92687">链接地址</a>：https://m.do.co/c/bff6eed92687 开启你的DO主机之路。</p>
<p>Gopher Daily(Gopher每日新闻)归档仓库 &#8211; https://github.com/bigwhite/gopherdaily</p>
<p>我的联系方式：</p>
<ul>
<li>微博：https://weibo.com/bigwhite20xx</li>
<li>微信公众号：iamtonybai</li>
<li>博客：tonybai.com</li>
<li>github: https://github.com/bigwhite</li>
<li>“Gopher部落”知识星球：https://public.zsxq.com/groups/51284458844544</li>
</ul>
<p>微信赞赏：<br />
<img src="https://tonybai.com/wp-content/uploads/wechat-zanshang-code-512x512.jpg" alt="img{512x368}" /></p>
<p>商务合作方式：撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。</p>
<p style='text-align:left'>&copy; 2021, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2021/04/09/ten-commandments-of-go/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>写Go代码时遇到的那些问题[第2期]</title>
		<link>https://tonybai.com/2018/01/27/the-problems-i-encountered-when-writing-go-code-issue-2nd/</link>
		<comments>https://tonybai.com/2018/01/27/the-problems-i-encountered-when-writing-go-code-issue-2nd/#comments</comments>
		<pubDate>Fri, 26 Jan 2018 16:47:53 +0000</pubDate>
		<dc:creator>bigwhite</dc:creator>
				<category><![CDATA[技术志]]></category>
		<category><![CDATA[Channel]]></category>
		<category><![CDATA[Concurrency]]></category>
		<category><![CDATA[dep]]></category>
		<category><![CDATA[error-handling]]></category>
		<category><![CDATA[Git]]></category>
		<category><![CDATA[Go]]></category>
		<category><![CDATA[go1.4]]></category>
		<category><![CDATA[Golang]]></category>
		<category><![CDATA[GOPATH]]></category>
		<category><![CDATA[Gopher]]></category>
		<category><![CDATA[GopherCon]]></category>
		<category><![CDATA[Gopkg.lock]]></category>
		<category><![CDATA[Gopkg.toml]]></category>
		<category><![CDATA[goroutine]]></category>
		<category><![CDATA[Interface]]></category>
		<category><![CDATA[Mock]]></category>
		<category><![CDATA[select]]></category>
		<category><![CDATA[Setup]]></category>
		<category><![CDATA[teardown]]></category>
		<category><![CDATA[Testing]]></category>
		<category><![CDATA[TestMain]]></category>
		<category><![CDATA[Timer]]></category>
		<category><![CDATA[TOML]]></category>
		<category><![CDATA[Unittest]]></category>
		<category><![CDATA[vendor]]></category>
		<category><![CDATA[单元测试]]></category>
		<category><![CDATA[错误处理]]></category>

		<guid isPermaLink="false">http://tonybai.com/?p=2536</guid>
		<description><![CDATA[第1期的“写Go代码时遇到的那些问题”一经发布后得到了很多Gopher的支持和赞赏，这也是我继续写下去的动力！不过这里依然要强调的是这一系列文章反映的是笔者在实践中对代码编写的认知以及代码的演化过程。这里的代码也许只是“中间阶段”，并不是什么最优的结果，我记录的只是对问题、对代码的一个思考历程。不过，十分欢迎交流与批评指正。 一、dep的日常操作 虽然dep在国内使用依然有init失败率较高（因为一些qiang外的第三方package）的坎儿，但我和主流Gopher社区和项目一样，义无反顾地选择在代码库中使用dep。本周dep刚刚发布了0.4.1版本，与之前版本最大的不同在于dep发布了其官网以及相对完整的文档（以替代原先在github项目主页上的简陋的、格式较low的FAQ），这也是dep继续走向成熟的一个标志。不过关于dep何时能merge到go tools链当中，目前还是未知数。不过dep会在相当长的一段时期继续以独立工具的形式存在，直到merge到Go tools中并被广泛接受。 包依赖管理工具在日常开发中并不需要太多的存在感，我们需要的这类工具特征是功能强大但接口“小”，对开发者体验好，不太需要太关心其运行原理，dep基本符合。dep日常操作最主要的三个命令：dep init、dep ensure和dep status。在《初窥dep》一文中，我曾重点说过dep init原理，这里就不重点说了，我们用一个例子来说说使用dep的日常workflow。 1、dep init empty project 我们可以对一个empty project或一个初具框架雏形的project进行init，这里init一个empty project，作为后续的示例基础： ➜ $GOPATH/src/depdemo $dep init -v Getting direct dependencies... Checked 1 directories for packages. Found 0 direct dependencies. Root project is "depdemo" 0 transitively valid internal packages 0 external packages imported from 0 projects (0) ✓ select (root) ✓ [...]]]></description>
			<content:encoded><![CDATA[<p><a href="http://tonybai.com/2018/01/13/the-problems-i-encountered-when-writing-go-code-issue-1st/">第1期的“写Go代码时遇到的那些问题”</a>一经发布后得到了很多Gopher的支持和赞赏，这也是我继续写下去的动力！不过这里依然要强调的是这一系列文章反映的是笔者在实践中对代码编写的认知以及代码的演化过程。这里的代码也许只是“中间阶段”，并不是什么最优的结果，我记录的只是对问题、对代码的一个思考历程。不过，十分欢迎交流与批评指正。</p>
<h2>一、dep的日常操作</h2>
<p>虽然<a href="https://github.com/golang/dep">dep</a>在国内使用依然有init失败率较高（因为一些qiang外的第三方package）的坎儿，但我和主流Gopher社区和项目一样，义无反顾地选择在代码库中<a href="http://tonybai.com/2017/06/08/first-glimpse-of-dep/">使用dep</a>。本周dep刚刚发布了<a href="https://golang.github.io/dep/blog/2018/01/23/announce-v0.4.0.html">0.4.1版本</a>，与之前版本最大的不同在于dep发布了其官网以及相对完整的文档（以替代原先在github项目主页上的简陋的、格式较low的FAQ），这也是dep继续走向成熟的一个标志。不过关于dep何时能merge到go tools链当中，目前还是未知数。不过dep会在相当长的一段时期继续以独立工具的形式存在，直到merge到Go tools中并被广泛接受。</p>
<p>包依赖管理工具在日常开发中并不需要太多的存在感，我们需要的这类工具特征是功能强大但接口“小”，对开发者体验好，不太需要太关心其运行原理，dep基本符合。dep日常操作最主要的三个命令：dep init、dep ensure和dep status。在<a href="http://tonybai.com/2017/06/08/first-glimpse-of-dep/">《初窥dep》</a>一文中，我曾重点说过dep init原理，这里就不重点说了，我们用一个例子来说说使用dep的日常workflow。</p>
<h3>1、dep init empty project</h3>
<p>我们可以对一个empty project或一个初具框架雏形的project进行init，这里init一个empty project，作为后续的示例基础：</p>
<pre><code>➜  $GOPATH/src/depdemo $dep init -v
Getting direct dependencies...
Checked 1 directories for packages.
Found 0 direct dependencies.
Root project is "depdemo"
 0 transitively valid internal packages
 0 external packages imported from 0 projects
(0)   ✓ select (root)
  ✓ found solution with 0 packages from 0 projects

Solver wall times by segment:
  select-root: 68.406µs
        other:  9.806µs

  TOTAL: 78.212µs

➜  $GOPATH/src/depdemo $ls
Gopkg.lock    Gopkg.toml    vendor/

➜  $GOPATH/src/depdemo $dep status
PROJECT  CONSTRAINT  VERSION  REVISION  LATEST  PKGS USED

</code></pre>
<p>dep init有三个输出：Gopkg.lock、Gopkg.toml和vendor目录，其中Gopkg.toml（包含example，但注释掉了）和vendor都是空的，Gopkg.lock中仅包含了一些给<a href="https://github.com/golang/dep/tree/master/gps">gps</a>使用的metadata：</p>
<pre><code>➜  $GOPATH/src/depdemo git:(a337d5b) $cat Gopkg.lock
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.

[solve-meta]
  analyzer-name = "dep"
  analyzer-version = 1
  inputs-digest = "ab4fef131ee828e96ba67d31a7d690bd5f2f42040c6766b1b12fe856f87e0ff7"
  solver-name = "gps-cdcl"
  solver-version = 1
</code></pre>
<h3>2、常规操作循环：for { 填代码 -> dep ensure }</h3>
<p>接下来的常规操作就是我们要为project添加代码了。我们先来为工程添加一个main.go文件，源码如下：</p>
<pre><code>// main.go
package main

import "fmt"

func main() {
    fmt.Println("depdemo")
}
</code></pre>
<p>这份代码的依赖只是std库的fmt，并没有使用第三方的依赖，因此当我们通过dep status查看当前状态、使用ensure去做同步时，发现dep并没有什么要做的：</p>
<pre><code>➜  $GOPATH/src/depdemo $dep status
PROJECT  CONSTRAINT  VERSION  REVISION  LATEST  PKGS USED
➜  $GOPATH/src/depdemo $dep ensure -v
Gopkg.lock was already in sync with imports and Gopkg.toml
</code></pre>
<p>好吧。我们再来为main.go添点“有用”的内容：一段读取toml配置文件的代码。</p>
<pre><code>//data.toml
id = "12345678abcdefgh"
name = "tonybai"
city = "shenyang"

// main.go
package main

import (
    "fmt"
    "log"

    "github.com/BurntSushi/toml"
)

type Person struct {
    ID   string
    Name string
    City string
}

func main() {
    p := Person{}
    if _, err := toml.DecodeFile("./data.toml", &amp;p); err != nil {
        log.Fatal(err)
    }

    fmt.Println(p)
}

</code></pre>
<p>之后，再来执行dep status：</p>
<pre><code>➜  $GOPATH/src/depdemo $dep status
Lock inputs-digest mismatch due to the following packages missing from the lock:

PROJECT                     MISSING PACKAGES
github.com/BurntSushi/toml  [github.com/BurntSushi/toml]

This happens when a new import is added. Run `dep ensure` to install the missing packages.
input-digest mismatch
</code></pre>
<p>我们看到dep status检测到项目出现”不同步”的情况（代码中引用的toml包在Gopkg.lock中没有），并建议使用dep ensure命令去做一次sync。</p>
<p><img src="http://tonybai.com/wp-content/uploads/writing-go-code-issues/2nd-issue/dep-ensure-typical-flow.png" alt="img{512x368}" /></p>
<p>我们来ensure一下(ensure的输入输出见上图)：</p>
<pre><code>$GOPATH/src/depdemo git:(master) $dep ensure -v
Root project is "depdemo"
 1 transitively valid internal packages
 1 external packages imported from 1 projects
(0)   ✓ select (root)

(1)    ? attempt github.com/BurntSushi/toml with 1 pkgs; 7 versions to try
(1)        try github.com/BurntSushi/toml@v0.3.0
(1)    ✓ select github.com/BurntSushi/toml@v0.3.0 w/1 pkgs
  ✓ found solution with 1 packages from 1 projects

Solver wall times by segment:
     b-source-exists: 15.821158205s
... ...
  b-deduce-proj-root:       5.453µs

  TOTAL: 16.176846089s

(1/1) Wrote github.com/BurntSushi/toml@v0.3.0

</code></pre>
<p>我们来看看项目中的文件都发生了哪些变化：</p>
<pre><code>$git status
On branch master
Changes not staged for commit:
  (use "git add &lt;file&gt;..." to update what will be committed)
  (use "git checkout -- &lt;file&gt;..." to discard changes in working directory)

    modified:   Gopkg.lock

Untracked files:
  (use "git add &lt;file&gt;..." to include in what will be committed)

    vendor/
</code></pre>
<p>可以看到Gopkg.lock文件和vendor目录下发生了变化：</p>
<pre><code>$git diff

diff --git a/Gopkg.lock b/Gopkg.lock
index bef2d00..c5ae854 100644
--- a/Gopkg.lock
+++ b/Gopkg.lock
@@ -1,9 +1,15 @@
 # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.

+[[projects]]
+  name = "github.com/BurntSushi/toml"
+  packages = ["."]
+  revision = "b26d9c308763d68093482582cea63d69be07a0f0"
+  version = "v0.3.0"
+
 [solve-meta]
   analyzer-name = "dep"
   analyzer-version = 1
-  inputs-digest = "ab4fef131ee828e96ba67d31a7d690bd5f2f42040c6766b1b12fe856f87e0ff7"
+  inputs-digest = "25c744eb70aefb94032db749509fd34b2fb6e7c6041e8b8c405f7e97d10bdb8d"
   solver-name = "gps-cdcl"
   solver-version = 1

$tree -L 2 vendor
vendor
└── github.com
    └── BurntSushi

</code></pre>
<p>可以看到Gopkg.lock中增加了toml包的依赖条目(版本v0.3.0)，input-digest这个元数据字段的值也发生了变更；并且vendor目录下多了toml包的源码，至此项目又到达了“同步”状态。</p>
<h3>3、添加约束</h3>
<p>大多数情况下，我们到这里就算完成了<strong>dep work flow的一次cycle</strong>，但如果你需要为第三方包的版本加上一些约束条件，那么dep ensure -add就会派上用场，比如说：我们要使用toml包的v0.2.x版本，而不是v0.3.0版本，我们需要为github.com/BurntSushi/toml添加一条约束：</p>
<pre><code>$dep ensure -v -add github.com/BurntSushi/toml@v0.2.0
Fetching sources...
(1/1) github.com/BurntSushi/toml@v0.2.0

Root project is "depdemo"
 1 transitively valid internal packages
 1 external packages imported from 1 projects
(0)   ✓ select (root)
(1)    ? attempt github.com/BurntSushi/toml with 1 pkgs; at least 1 versions to try
(1)        try github.com/BurntSushi/toml@v0.3.0
(2)    ✗   github.com/BurntSushi/toml@v0.3.0 not allowed by constraint ^0.2.0:
(2)        ^0.2.0 from (root)
(1)        try github.com/BurntSushi/toml@v0.2.0
(1)    ✓ select github.com/BurntSushi/toml@v0.2.0 w/1 pkgs
  ✓ found solution with 1 packages from 1 projects

Solver wall times by segment:
... ...

  TOTAL: 599.252392ms

(1/1) Wrote github.com/BurntSushi/toml@v0.2.0
</code></pre>
<p>add约束后，Gopkg.toml中增加了一条记录：</p>
<pre><code>// Gopkg.toml
[[constraint]]
  name = "github.com/BurntSushi/toml"
  version = "0.2.0"

</code></pre>
<p>Gopkg.lock中的toml条目的版本回退为v0.2.0：</p>
<pre><code>diff --git a/Gopkg.lock b/Gopkg.lock
index c5ae854..a557251 100644
--- a/Gopkg.lock
+++ b/Gopkg.lock
@@ -4,12 +4,12 @@
 [[projects]]
   name = "github.com/BurntSushi/toml"
   packages = ["."]
-  revision = "b26d9c308763d68093482582cea63d69be07a0f0"
-  version = "v0.3.0"
+  revision = "bbd5bb678321a0d6e58f1099321dfa73391c1b6f"
+  version = "v0.2.0"

 [solve-meta]
   analyzer-name = "dep"
   analyzer-version = 1
-  inputs-digest = "25c744eb70aefb94032db749509fd34b2fb6e7c6041e8b8c405f7e97d10bdb8d"
+  inputs-digest = "9fd144de0cc448be93418c927b5ce2a70e03ec7f260fa7e0867f970ff121c7d7"
   solver-name = "gps-cdcl"
   solver-version = 1

$dep status
PROJECT                     CONSTRAINT  VERSION  REVISION  LATEST  PKGS USED
github.com/BurntSushi/toml  ^0.2.0      v0.2.0   bbd5bb6   v0.2.0  1

</code></pre>
<p>vendor目录下的toml包源码也回退到v0.2.0的源码。关于约束规则的构成语法，可以<a href="https://golang.github.io/dep/docs/Gopkg.toml.html#Version">参考dep文档</a>。</p>
<h3>4、revendor/update vendor</h3>
<p>使用<a href="http://tonybai.com/2015/07/31/understand-go15-vendor/">vendor机制</a>后，由于第三方依赖包修正bug或引入你需要的功能，revendor第三方依赖包版本或者叫update vendor会成为一个周期性的工作。比如：toml包做了一些bugfix，并发布了v0.2.1版本。在我的depdemo中，为了一并fix掉这些bug，我需要重新vendor toml包。之前我们加的constraint是满足升级到v0.2.1版本的，因此我们不需要重新设置constraints，我们只需要单独revendor toml即可，可以使用dep ensure -update 命令：</p>
<pre><code>$dep ensure -v -update github.com/BurntSushi/toml
Root project is "depdemo"
 1 transitively valid internal packages
 1 external packages imported from 1 projects
(0)   ✓ select (root)
(1)    ? attempt github.com/BurntSushi/toml with 1 pkgs; 7 versions to try
(1)        try github.com/BurntSushi/toml@v0.3.0
(2)    ✗   github.com/BurntSushi/toml@v0.3.0 not allowed by constraint ^0.2.0:
(2)        ^0.2.0 from (root)
(1)        try github.com/BurntSushi/toml@v0.2.0
(1)    ✓ select github.com/BurntSushi/toml@v0.2.0 w/1 pkgs
  ✓ found solution with 1 packages from 1 projects

Solver wall times by segment:
  b-list-versions: 1m18.267880815s
  .... ...
  TOTAL: 1m57.118656393s
</code></pre>
<p>由于真实的toml并没有v0.2.1版本且没有v0.2.x版本，因此我们的dep ensure -update并没有真正获取到数据。vendor和Gopkg.lock都没有变化。</p>
<h3>5、dep日常操作小结</h3>
<p>下面这幅图包含了上述三个dep日常操作，可以直观地看出不同操作后，对项目带来的改变：</p>
<p><img src="http://tonybai.com/wp-content/uploads/writing-go-code-issues/2nd-issue/dep-daily-workflows.png" alt="img{512x368}" /></p>
<p>“工欲善其事，必先利其器”，熟练的掌握dep的日常操作流程对提升开发效率大有裨益。</p>
<h2>二、“超时等待退出”框架的一种实现</h2>
<p>很多时候，我们在程序中都要启动多个goroutine协作完成应用的业务逻辑，比如：</p>
<pre><code>func main() {
    go producer.Start()
    go consumer.Start()
    go watcher.Start()
    ... ...
}
</code></pre>
<p>启动容易停止难！当程序要退出时，最粗暴的方法就是不管三七二十一，main goroutine直接退出；优雅些的方式，也是*nix系统通常的作法是：通知一下各个Goroutine要退出了，然后等待一段时间后再真正退出。粗暴地直接退出的方式可能会导致业务数据的损坏、不完整或丢失。等待超时的方式虽然不能完全避免“损失”，但是它给了各个goroutine一个“挽救数据”的机会，可以尽可能地减少损失的程度。</p>
<p>但这些goroutine形态很可能不同，有些是server，有些可能是client worker或其manager，因此似乎很难用一种统一的框架全面管理他们的启动、运行和退出，于是我们缩窄“交互面”，我们只做“超时等待退出”。我们定义一个interface：</p>
<pre><code>type GracefullyShutdowner interface {
    Shutdown(waitTimeout time.Duration) error
}

</code></pre>
<p>这样，凡是实现了该interface的类型均可在程序退出时得到退出的通知，并有机会做退出前的最后清理工作。这里还提供了一个类似http.HandlerFunc的类型ShutdownerFunc ，用于将普通function转化为实现了GracefullyShutdowner interface的类型实例：</p>
<pre><code>type ShutdownerFunc func(time.Duration) error

func (f ShutdownerFunc) Shutdown(waitTimeout time.Duration) error {
    return f(waitTimeout)
}

</code></pre>
<h3>1、并发退出</h3>
<p>退出也至少有两种类型，一种是并发退出，这种退出方式下各个goroutine的退出先后次序对数据处理无影响；另外一种则是顺序退出，即各个goroutine之间的退出是必须按照一定次序进行的。我们先来说并发退出。上代码！</p>
<pre><code>// shutdown.go
func ConcurrencyShutdown(waitTimeout time.Duration, shutdowners ...GracefullyShutdowner) error {
    c := make(chan struct{})

    go func() {
        var wg sync.WaitGroup
        for _, g := range shutdowners {
            wg.Add(1)
            go func(shutdowner GracefullyShutdowner) {
                shutdowner.Shutdown(waitTimeout)
                wg.Done()
            }(g)
        }
        wg.Wait()
        c &lt;- struct{}{}
    }()

    select {
    case &lt;-c:
        return nil
    case &lt;-time.After(waitTimeout):
        return errors.New("wait timeout")
    }
}
</code></pre>
<p>我们将各个GracefullyShutdowner接口的实现以一个变长参数的形式传入ConcurrencyShutdown函数。ConcurrencyShutdown函数实现也很简单，通过：</p>
<ul>
<li>为每个shutdowner启动一个goroutine实现并发退出，并将timeout参数传入shutdowner的Shutdown方法中；</li>
<li>sync.WaitGroup在外层等待每个goroutine的退出；</li>
<li>通过select一个退出指示<a href="http://tonybai.com/2014/09/29/a-channel-compendium-for-golang/">channel</a>和time.After返回的<a href="http://tonybai.com/2016/12/21/how-to-use-timer-reset-in-golang-correctly/">timer</a> channel来决定到底是正常退出还是超时退出。</li>
</ul>
<p>该函数的具体使用方法可以参考：shutdown_test.go。</p>
<pre><code>//shutdown_test.go
func shutdownMaker(processTm int) func(time.Duration) error {
    return func(time.Duration) error {
        time.Sleep(time.Second * time.Duration(processTm))
        return nil
    }
}

func TestConcurrencyShutdown(t *testing.T) {
    f1 := shutdownMaker(2)
    f2 := shutdownMaker(6)

    err := ConcurrencyShutdown(time.Duration(10)*time.Second, ShutdownerFunc(f1), ShutdownerFunc(f2))
    if err != nil {
        t.Errorf("want nil, actual: %s", err)
        return
    }

    err = ConcurrencyShutdown(time.Duration(4)*time.Second, ShutdownerFunc(f1), ShutdownerFunc(f2))
    if err == nil {
        t.Error("want timeout, actual nil")
        return
    }
}
</code></pre>
<h3>2、串行退出</h3>
<p>有了并发退出作为基础，串行退出也很简单了！</p>
<pre><code>//shutdown.go
func SequentialShutdown(waitTimeout time.Duration, shutdowners ...GracefullyShutdowner) error {
    start := time.Now()
    var left time.Duration

    for _, g := range shutdowners {
        elapsed := time.Since(start)
        left = waitTimeout - elapsed

        c := make(chan struct{})
        go func(shutdowner GracefullyShutdowner) {
            shutdowner.Shutdown(left)
            c &lt;- struct{}{}
        }(g)

        select {
        case &lt;-c:
            //continue
        case &lt;-time.After(left):
            return errors.New("wait timeout")
        }
    }

    return nil
}
</code></pre>
<p>串行退出的一个问题是waitTimeout的确定，因为这个超时时间是所有goroutine的退出时间之和。在上述代码里，我把每次的lefttime传入下一个要执行的goroutine的Shutdown方法中，外部select也同样使用这个left作为timeout的值。对照ConcurrencyShutdown，SequentialShutdown更简单，这里就不详细说了。</p>
<h3>3、小结</h3>
<p>这是一个可用的、抛砖引玉式的实现，但还有很多改进空间，比如：可以考虑一下获取每个shutdowner.Shutdown后的返回值(error)，留给大家自行考量吧。</p>
<h2>三、Testcase的setUp和tearDown</h2>
<p>Go语言自带<a href="http://tonybai.com/2014/10/22/golang-testing-techniques/">testing框架</a>，事实证明这是Go语言的一个巨大优势之一，Gopher们也非常喜欢这个testing包。但Testing这个事情比较复杂，有些场景还需要我们自己动脑筋在标准testing框架下实现需要的功能，比如：当测试代码需要访问外部数据库、Redis或连接远端server时。遇到这种情况，很多人想到了Mock，没错。Mock技术在一定程度上可以解决这些问题，但如果使用mock技术，业务代码就得为了test而去做一层抽象，提升了代码理解的难度，在有些时候这还真不如直接访问真实的外部环境。</p>
<p>这里先不讨论这两种方式的好坏优劣，这里仅讨论如果在testing中访问真实环境我们该如何测试。在经典<a href="http://tonybai.com/tag/Unittest">单元测试</a>框架中，我们经常能看到setUp和tearDown两个方法，它们分别用于在testcase执行之前初始化testcase的执行环境以及在testcase执行后清理执行环境，以保证每两个testcase之间都是独立的、互不干扰的。在真实环境下进行测试，我们也可以利用setUp和tearDown来为每个testcase初始化和清理case依赖的真实环境。</p>
<p>setUp和tearDown也是有级别的，有全局级、testsuite级以及testcase级。在Go中，在标准testing框架下，我们接触到的是全局级和testcase级别。Go中对全局级的setUp和tearDown的支持还要追溯到<a href="http://tonybai.com/2014/11/04/some-changes-in-go-1-4/">Go 1.4</a>，<a href="https://golang.org/doc/go1.4">Go 1.4</a>引入了TestMain方法，支持在诸多testcase执行之前为测试代码添加自定义setUp，以及在testing执行之后进行tearDown操作，例如：</p>
<pre><code>func TestMain(m *testing.M) {
    err := setup()
    if err != nil {
        fmt.Println(err)
        os.Exit(-1)
    }

    r := m.Run()
    teardown()

    os.Exit(r)
}
</code></pre>
<p>但在testcase级别，Go testing包并没有提供方法上的支持。在2017年的<a href="https://www.gophercon.com/">GopherCon</a>大会上，<a href="https://hashicorp.com/">Hashicorp</a>的创始人<a href="https://github.com/mitchellh">Mitchell Hashimoto</a>做了题为：<a href="https://www.youtube.com/watch?v=8hQG7QlcLBk">“Advanced Testing in Go”</a>的主题演讲，这份资料里提出了一种较为优雅的为testcase进行setUp和teawDown的方法：</p>
<pre><code>//setup-teardown-demo/foo_test.go
package foo_test

import (
    "fmt"
    "testing"
)

func setUp(t *testing.T, args ...interface{}) func() {
    fmt.Println("testcase setUp")
    // use t and args

    return func() {
        // use t
        // use args
        fmt.Println("testcase tearDown")
    }
}

func TestXXX(t *testing.T) {
    defer setUp(t)()
    fmt.Println("invoke testXXX")
}
</code></pre>
<p>这个方案充分利用了函数这个first-class type以及闭包的作用，每个Testcase可以定制自己的setUp和tearDown，也可以使用通用的setUp和tearDown，执行的效果如下：</p>
<pre><code>$go test -v .
=== RUN   TestXXX
testcase setUp
invoke testXXX
testcase tearDown
--- PASS: TestXXX (0.00s)
PASS
ok      github.com/bigwhite/experiments/writing-go-code-issues/2nd-issue/setup-teardown-demo    0.010s

</code></pre>
<h2>四、错误处理</h2>
<p>本来想码一些关于Go错误处理的文字，但发现自己在2015年就写过一篇旧文<a href="http://tonybai.com/2015/10/30/error-handling-in-go/">《Go语言错误处理》</a>，对Go错误处理的方方面面总结的很全面了。即便到今天也不过时，这当然也得益于<a href="https://golang.org/doc/go1compat">Go1兼容规范</a>的存在。因此有兴趣于此的朋友们，请移步到<a href="http://tonybai.com/2015/10/30/error-handling-in-go/">《Go语言错误处理》</a>这篇文章吧。</p>
<p>注：本文所涉及的示例代码，请到<a href="https://github.com/bigwhite/experiments/tree/master/writing-go-code-issues/2nd-issue">这里</a>下载。</p>
<hr />
<p>微博：<a href="http://weibo.com/bigwhite20xx">@tonybai_cn</a><br />
微信公众号：iamtonybai<br />
github.com: https://github.com/bigwhite</p>
<p>微信赞赏：<br />
<img src="http://tonybai.com/wp-content/uploads/wechat-zanshang-code-512x512.jpg" alt="img{512x368}" /></p>
<p style='text-align:left'>&copy; 2018, <a href='https://tonybai.com'>bigwhite</a>. 版权所有. </p>
]]></content:encoded>
			<wfw:commentRss>https://tonybai.com/2018/01/27/the-problems-i-encountered-when-writing-go-code-issue-2nd/feed/</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
	</channel>
</rss>
