Let's Go Further ساخت، versioning و کنترل کیفیت › مدیریت و خودکارسازی شماره نسخه‌ها
قبلی · فهرست مطالب · بعدی
فصل ۱۹.۶.

مدیریت و خودکارسازی شماره نسخه‌ها

در همان ابتدای این کتاب، ما شماره نسخه برنامه خود را به صورت ثابت در مقدار ثابت "1.0.0" در فایل cmd/api/main.go تعریف کردیم.

در این فصل، اقداماتی انجام می‌دهیم تا مشاهده و مدیریت این شماره نسخه آسان‌تر شود، و همچنین توضیح می‌دهیم که چگونه می‌توانید شماره نسخه‌ها را به صورت خودکار بر اساس Git commits تولید کنید و آنها را در برنامه خود ادغام کنید.

نمایش شماره نسخه

بیایید با به‌روزرسانی برنامه خود شروع کنیم تا بتوانیم به راحتی شماره نسخه را با اجرای binary با پرچم -version در خط فرمان بررسی کنیم، مشابه این:

$ ./bin/api -version
Version:        1.0.0

از نظر مفهومی، پیاده‌سازی این قابلیت نسبتاً ساده است. ما باید یک پرچم version boolean در خط فرمان تعریف کنیم، وجود این پرچم را هنگام راه‌اندازی بررسی کنیم و سپس در صورت لزوم شماره نسخه را چاپ کرده و برنامه را ببندیم.

اگر می‌خواهید همراه با ما پیش بروید، فایل cmd/api/main.go خود را به صورت زیر به‌روزرسانی کنید:

فایل: cmd/api/main.go
package main

import (
    "context"
    "database/sql"
    "expvar"
    "flag"
    "fmt" // New import
    "log/slog"
    "os"
    "runtime"
    "strings"
    "sync"
    "time"

    "greenlight.alexedwards.net/internal/data"
    "greenlight.alexedwards.net/internal/mailer"

    _ "github.com/lib/pq"
)

const version = "1.0.0"

...

func main() {
    var cfg config

    flag.IntVar(&cfg.port, "port", 4000, "API server port")
    flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)")

    flag.StringVar(&cfg.db.dsn, "db-dsn", "", "PostgreSQL DSN")
    
    flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections")
    flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections")
    flag.DurationVar(&cfg.db.maxIdleTime, "db-max-idle-time", 15*time.Minute, "PostgreSQL max connection idle time")

    flag.BoolVar(&cfg.limiter.enabled, "limiter-enabled", true, "Enable rate limiter")
    flag.Float64Var(&cfg.limiter.rps, "limiter-rps", 2, "Rate limiter maximum requests per second")
    flag.IntVar(&cfg.limiter.burst, "limiter-burst", 4, "Rate limiter maximum burst")

    flag.StringVar(&cfg.smtp.host, "smtp-host", "sandbox.smtp.mailtrap.io", "SMTP host")
    flag.IntVar(&cfg.smtp.port, "smtp-port", 25, "SMTP port")
    flag.StringVar(&cfg.smtp.username, "smtp-username", "a7420fc0883489", "SMTP username")
    flag.StringVar(&cfg.smtp.password, "smtp-password", "e75ffd0a3aa5ec", "SMTP password")
    flag.StringVar(&cfg.smtp.sender, "smtp-sender", "Greenlight <no-reply@greenlight.alexedwards.net>", "SMTP sender")

    flag.Func("cors-trusted-origins", "Trusted CORS origins (space separated)", func(val string) error {
        cfg.cors.trustedOrigins = strings.Fields(val)
        return nil
    })

    // Create a new version boolean flag with the default value of false.
    displayVersion := flag.Bool("version", false, "Display version and exit")

    flag.Parse()

    // If the version flag value is true, then print out the version number and
    // immediately exit.
    if *displayVersion {
        fmt.Printf("Version:\t%s\n", version)
        os.Exit(0)
    }

    ...
}

...

خب، بیایید این را امتحان کنیم. binary‌ها را با استفاده از make build/api مجدداً بسازید، سپس binary ./bin/api را با پرچم -version اجرا کنید.

باید مشاهده کنید که شماره نسخه را چاپ می‌کند و سپس خارج می‌شود، مشابه این:

$ make build/api 
Building cmd/api...
go build -ldflags="-s" -o="./bin/api" ./cmd/api
GOOS=linux GOARCH=amd64 go build -ldflags="-s" -o="./bin/linux_amd64/api" ./cmd/api

$ ./bin/api -version
Version:        1.0.0

شماره‌گذاری خودکار نسخه با Git

Go اطلاعات version control را در binary‌های شما هنگام اجرای go build روی یک package main که با Git، Mercurial، Fossil یا Bazaar ردیابی می‌شود، قرار می‌دهد.

دو روش برای دسترسی به این اطلاعات version control وجود دارد — یا با استفاده از دستور go version -m روی binary خود، یا از داخل کد برنامه خود با فراخوانی debug.ReadBuildInfo().

بیایید هر دو رویکرد را بررسی کنیم.

اگر می‌خواهید همراه با ما پیش بروید (و قبلاً این کار را انجام نداده‌اید)، لطفاً یک مخزن Git جدید در ریشه پروژه خود مقداردهی اولیه کنید:

$ git init
Initialized empty Git repository in /home/alex/Projects/greenlight/.git/

سپس یک commit جدید حاوی تمام فایل‌های پروژه خود ایجاد کنید، به صورت زیر:

$ git add .
$ git commit -m "Initial commit"

اگر تاریخچه commit خود را با استفاده از دستور git log مشاهده کنید، hash این commit را خواهید دید.

$ git log
commit 59bdb76fda0c15194ce18afae5d4875237f05ea9 (HEAD -> master)
Author: Alex Edwards 
Date:   Wed Feb 19 19:01:34 2025 +0100

    Initial commit

در مورد من، hash commit 59bdb76fda0c15194ce18afae5d4875237f05ea9 است — اما مال شما بسیار محتمل است که مقدار متفاوتی باشد.

بعد، دوباره make build را اجرا کنید تا binary جدیدی تولید شود و سپس از دستور go version -m روی آن استفاده کنید. به صورت زیر:

$ make build/api 
Building cmd/api...
go build -ldflags="-s" -o=./bin/api ./cmd/api
GOOS=linux GOARCH=amd64 go build -ldflags="-s" -o=./bin/linux_amd64/api ./cmd/api

$ go version -m ./bin/api 
./bin/api: go1.25.0
        path    greenlight.alexedwards.net/cmd/api
        mod     greenlight.alexedwards.net      v0.0.0-20250219190134-59bdb76fda0c
        dep     github.com/julienschmidt/httprouter     v1.3.0
        dep     github.com/lib/pq       v1.10.9
        dep     github.com/tomasen/realip       v0.0.0-20180522021738-f0c99a92ddce
        dep     github.com/wneessen/go-mail     v0.6.2
        dep     golang.org/x/crypto     v0.41.0
        dep     golang.org/x/text       v0.22.0
        dep     golang.org/x/time       v0.12.0
        build   -buildmode=exe
        build   -compiler=gc
        build   -ldflags=-s
        build   CGO_ENABLED=1
        build   CGO_CFLAGS=
        build   CGO_CPPFLAGS=
        build   CGO_CXXFLAGS=
        build   CGO_LDFLAGS=
        build   GOARCH=amd64
        build   GOOS=linux
        build   GOAMD64=v1
        build   vcs=git
        build   vcs.revision=59bdb76fda0c15194ce18afae5d4875237f05ea9
        build   vcs.time=2025-02-19T19:01:34Z
        build   vcs.modified=false

خروجی go version -m اطلاعات جالبی درباره binary به ما نشان می‌دهد. می‌توانیم نسخه Go که با آن ساخته شده را ببینیم (go1.25.0 در مورد من)، وابستگی‌های module و اطلاعات مربوط به تنظیمات build — از جمله پرچم‌های linker استفاده شده و OS و معماری که برای آن ساخته شده است.

با این حال، چیزهایی که در حال حاضر بیشتر به آنها علاقه‌مندیم خط mod و تنظیمات build vcs در انتهای خروجی هستند.

خط mod greenlight.alexedwards.net v0.0.0-20250219190134-59bdb76fda0c به ما می‌گوید که module اصلی در binary کامپایل شده greenlight.alexedwards.net است و module دارای نسخه v0.0.0-20250219190134-59bdb76fda0c است.

همانطور که به طور خلاصه در بالا ذکر کردم، تمام اطلاعاتی که در خروجی go version -m می‌بینید در زمان runtime نیز برای شما در دسترس است. به طور دقیق‌تر، می‌توانید با فراخوانی تابع debug.ReadBuildInfo() به آنها دسترسی پیدا کنید که یک struct debug.BuildInfo برمی‌گرداند که در اصل حاوی همان اطلاعاتی است که هنگام اجرای دستور go version -m دیدیم.

بیایید از این قابلیت استفاده کنیم و فایل main.go خود را طوری تطبیق دهیم که مقدار version به جای مقدار ثابت hardcoded "1.0.0"، به pseudo-version که درباره آن بحث کردیم تنظیم شود.

برای کمک به این کار، یک package کوچک internal/vcs شامل یک تابع Version() ایجاد می‌کنیم، به صورت زیر:

$ mkdir internal/vcs
$ touch internal/vcs/vcs.go
فایل: internal/vcs/vcs.go
package vcs

import (
	"fmt"
	"runtime/debug"
)

func Version() string {
    // Use debug.ReadBuildInfo() to retrieve a debug.BuildInfo struct. If this is available,
    // the ok value will be true, and we return the pseudo-version contained in the
    // Main.Version field.
    bi, ok := debug.ReadBuildInfo()
    if ok {
        return bi.Main.Version
    }

    return ""
}

حالا که این آماده است، بیایید به فایل main.go خود برگردیم و آن را طوری به‌روزرسانی کنیم که شماره نسخه با استفاده از تابع جدید vcs.Version() تنظیم شود:

فایل: cmd/api/main.go
package main

import (
    "context"
    "database/sql"
    "expvar"
    "flag"
    "fmt"
    "log/slog"
    "os"
    "runtime"
    "strings"
    "sync"
    "time"

    "greenlight.alexedwards.net/internal/data"
    "greenlight.alexedwards.net/internal/mailer"
    "greenlight.alexedwards.net/internal/vcs" // New import

    _ "github.com/lib/pq"
)

// Make version a variable (rather than a constant) and set its value to vcs.Version().
var (
    version = vcs.Version()
)

...

خب، بیایید این را امتحان کنیم. binary را دوباره بسازید…

$ make build/api 
Building cmd/api...
go build -ldflags="-s" -o=./bin/api ./cmd/api
GOOS=linux GOARCH=amd64 go build -ldflags="-s" -o=./bin/linux_amd64/api ./cmd/api

و سپس آن را با پرچم -version اجرا کنید:

$ ./bin/api -version
Version:        v0.0.0-20250219190134-59bdb76fda0c+dirty

عالی! اکنون شماره pseudo-version موجود در binary را گزارش می‌دهد. اما از آنجا که از زمان commit قبلی کد را تغییر داده‌ایم، pseudo-version اکنون پسوند +dirty دارد که نشان می‌دهد کدپایه تغییرات commit نشده دارد.

بیایید با commit کردن تغییرات اخیر خود این مشکل را برطرف کنیم…

$ git add .
$ git commit -m "Generate version number automatically"

و هنگامی که binary را مجدداً بسازید و شماره نسخه را دوباره بررسی کنید، باید شماره نسخه جدیدی بدون پسوند +dirty ببینید، مشابه این:

$ make build/api 
Building cmd/api...
go build -ldflags="-s" -o=./bin/api ./cmd/api
GOOS=linux GOARCH=amd64 go build -ldflags="-s" -o=./bin/linux_amd64/api ./cmd/api

$ ./bin/api -version
Version:        v0.0.0-20250221115919-f79a5dbadf36

برچسب زدن releases

در برخی پروژه‌ها ممکن است بخواهید برخی Git commits را با یک شماره نسخه semantic برچسب درج کنید، که معمولاً برای نشان دادن یک release رسمی است.

برای نشان دادن این مورد، بیایید برچسب v1.0.0 را به آخرین commit خود اضافه کنیم به صورت زیر:

$ git tag v1.0.0
$ git log
commit f79a5dbadf3665b825aef58b27406920ac6382d7 (HEAD -> master, tag: v1.0.0)
Author: Alex Edwards 
Date:   Fri Feb 21 12:59:19 2025 +0100

    Generate version number automatically

...

اگر برنامه را مجدداً بسازید و نسخه را دوباره بررسی کنید، باید مشاهده کنید که اکنون v1.0.0 به عنوان نسخه گزارش می‌شود، به جای pseudo-version تولید شده خودکار.

$ make build/api 
$ ./bin/api -version
Version:        v1.0.0

اکنون، فقط برای نشان دادن اینکه بعد چه اتفاقی می‌افتد، بیایید یک commit (خالی) دیگر به کدپایه خود اضافه کنیم.

$ git commit --allow-empty -m "Empty commit"
[master 5370944] Empty commit

در این مرحله، نسخه پایه ما هنوز v1.0.0 است، اما ما جلوتر از commit هستیم که آن را v1.0.0 برچسب زدیم. بنابراین اکنون Go دیگر v1.0.0 را به عنوان نسخه گزارش نمی‌کند و در عوض دوباره یک pseudo-version گزارش می‌دهد.

هنگامی که برنامه را مجدداً بسازید و آن را بررسی کنید، باید مشاهده کنید که نسخه چیزی شبیه به این است:

$ make build/api 
$ ./bin/api -version
Version:        v1.0.1-0.20250221122119-5370944738f9

timestamp و hash مربوط به آخرین commit هستند، درست مثل قبل. اما همچنین می‌توانیم ببینیم که بخش شماره نسخه semantic v1.0.1-0 است، که در نگاه اول کمی عجیب به نظر می‌رسد.

آنچه در اینجا اتفاق می‌افتد این است که Go قاعده زیر را اعمال می‌کند: اگر نسخه پایه vX.Y.Z دارید، اما آخرین commits شما جلوتر از آن هستند، پس ابتدای pseudo-version به فرمت vX.Y.(Z+1)-0 خواهد بود. برای اطلاعات بیشتر درباره دلیل این موضوع، لطفاً به Go Modules Reference مراجعه کنید.

پس در مجموع، این بسیار خوب است. ما شماره نسخه‌ای داریم که به صورت خودکار بر اساس تاریخچه Git ما تولید شده و در binary ما قرار گرفته است، و شناسایی اینکه دقیقاً چه کدی در یک binary خاص یا یک برنامه در حال اجرا استفاده شده آسان است — فقط کافی است binary را با پرچم -version اجرا کنید، یا endpoint healthcheck را فراخوانی کنید و سپس شماره نسخه را با تاریخچه مخزن Git مقایسه کنید.


اطلاعات تکمیلی

گزارش فقط timestamp و hash commit

اگر commits را برچسب نمی‌زنید و می‌خواهید فقط timestamp و hash آخرین commit را به عنوان شماره نسخه خود استفاده کنید، می‌توانید در فیلد debug.BuildInfo.Settings حلقه بزنید تا مقادیر vcs.time، vcs.revision و vcs.modified را استخراج کنید و رشته نسخه خود را ایجاد کنید. به صورت زیر:

func Version() string {
    var (
        time     string
        revision string
        modified bool
    )

    bi, ok := debug.ReadBuildInfo()
    if ok {
        for _, s := range bi.Settings {
            switch s.Key {
            case "vcs.time":
                time = s.Value
            case "vcs.revision":
                revision = s.Value
            case "vcs.modified":
                if s.Value == "true" {
                    modified = true
                }
            }
        }
    }

    if modified {
        return fmt.Sprintf("%s-%s+dirty", time, revision)
    }

    return fmt.Sprintf("%s-%s", time, revision)
}

استفاده از این روش شماره نسخه‌هایی مشابه این ایجاد می‌کند:

2025-02-21T10:16:24Z-1c9b6ff48ea800acdf4f5c6f5c3b62b98baf2bd7+dirty

استفاده از پرچم‌های linker

قبل از Go 1.18، روش رایج برای مدیریت خودکار شماره نسخه‌ها، ‘ burns-in’ کردن شماره نسخه هنگام ساخت binary با استفاده از پرچم linker -X بود. استفاده از debug.ReadBuildInfo() اکنون روش ترجیحی است، اما رویکرد قدیمی هنوز می‌تواند مفید باشد اگر بخواهید شماره نسخه را روی چیزی تنظیم کنید که از طریق debug.ReadBuildInfo() در دسترس نیست.

به عنوان مثال، اگر بخواهید شماره نسخه را روی مقدار متغیر محیطی VERSION در ماشینی که binary را می‌سازد تنظیم کنید، می‌توانید از پرچم linker -X برای ‘ burns-in’ کردن این مقدار به متغیر main.version استفاده کنید. به صورت زیر:

فایل: Makefile
...

## build/api: build the cmd/api application
.PHONY: build/api
build/api:
    @echo 'Building cmd/api...'
    go build -ldflags='-s -X main.version=${VERSION}' -o=./bin/api ./cmd/api
    GOOS=linux GOARCH=amd64 go build -ldflags='-s -X main.version=${VERSION}' -o=./bin/linux_amd64/api ./cmd/api