DEV Community

EME GUG
EME GUG

Posted on

Your Go Module Path Is a Hidden Dependency: Decoupling Go Code from GitHub

Hầu hết các Go project mình từng review đều bắt đầu bằng cùng một dòng: module github.com/ten-cong-ty/ten-repo. Nghe thì vô hại, ai cũng làm vậy, go mod init cũng gợi ý vậy. Nhưng dòng này âm thầm biến GitHub thành một phần của API công khai của bạn. Đến ngày công ty đổi tên organization, chuyển sang GitLab self-hosted hay Gitea, hoặc repo bị tách/gộp, bạn sẽ thấy cái giá: mọi import path trong mọi service phụ thuộc đều phải sửa, mọi go.sum đều thay đổi, và người dùng bên ngoài thì bị gãy build. Bài này chia sẻ cách mình tách Go code khỏi GitHub một cách thực tế: vanity import path, cấu hình module proxy, và không để CI hay code nghiệp vụ dính chặt vào một nền tảng cụ thể.

Vì sao module path lại là "coupling"?

Trong Go, module path không chỉ là một cái tên. Nó đồng thời là:

  1. Định danh của package trong mọi câu lệnh import.
  2. Địa chỉ để go command tìm source code (nếu không qua proxy).
  3. Khóa trong checksum database (sum.golang.org) và trong go.sum.

Khi bạn viết github.com/acme/payment, bạn đã gắn cả ba thứ trên vào một hostname mà bạn không kiểm soát. Đổi host = đổi identity = về mặt kỹ thuật là một module hoàn toàn mới. Go không có cơ chế "redirect" ở cấp module path; directive retract hay comment // Deprecated: trong go.mod chỉ giúp báo cho người dùng, không tự chuyển họ sang path mới.

Mình từng chứng kiến một team ở Hà Nội mất gần hai tuần chỉ để migrate khoảng 40 internal service sau khi organization trên GitHub bị đổi tên do tái cấu trúc công ty. Phần lớn thời gian không phải code, mà là chờ từng team merge PR sửa import và debug lỗi go.sum mismatch.

Vanity import path: một lớp indirection rẻ tiền

Giải pháp kinh điển là dùng domain của chính bạn làm module path, ví dụ go.acme.vn/payment. Khi go command gặp path này, nó gửi request https://go.acme.vn/payment?go-get=1 và đọc thẻ <meta name="go-import"> để biết code thật nằm ở đâu.

sequenceDiagram
    participant Dev as go get
    participant Proxy as GOPROXY
    participant Vanity as go.acme.vn
    participant VCS as GitHub / GitLab
    Dev->>Proxy: GET go.acme.vn/payment/@v/list
    Proxy->>Vanity: GET /payment?go-get=1
    Vanity-->>Proxy: meta go-import -> repo URL
    Proxy->>VCS: git fetch
    VCS-->>Proxy: source
    Proxy-->>Dev: zip + go.mod

Bạn không cần framework gì cả. Một server Go nhỏ khoảng 40 dòng là đủ, có thể deploy lên bất cứ đâu (Cloud Run, một VPS, hoặc thậm chí static HTML trên CDN):

// cmd/vanity/main.go
package main

import (
    "fmt"
    "log"
    "net/http"
    "strings"
)

const host = "go.acme.vn"

// Chỉ cần sửa map này khi chuyển repo sang nền tảng khác.
var repos = map[string]string{
    "payment": "https://github.com/acme/payment",
    "authkit": "https://gitlab.acme.vn/platform/authkit",
}

func handler(w http.ResponseWriter, r *http.Request) {
    name := strings.SplitN(strings.Trim(r.URL.Path, "/"), "/", 2)[0]
    repo, ok := repos[name]
    if !ok {
        http.NotFound(w, r)
        return
    }
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    fmt.Fprintf(w, `<!DOCTYPE html><html><head>
<meta name="go-import" content="%s/%s git %s">
<meta name="go-source" content="%s/%s %s %s/tree/main{/dir} %s/blob/main{/dir}/{file}#L{line}">
</head><body>go get %s/%s</body></html>`,
        host, name, repo, host, name, repo, repo, repo, host, name)
}

func main() {
    http.HandleFunc("/", handler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}
Enter fullscreen mode Exit fullscreen mode

Lưu ý quan trọng: phần đầu tiên trong content (import prefix) phải khớp chính xác với dòng module trong go.mod của repo, nếu không go sẽ báo lỗi module declares its path as ... but was required as .... Ngày cần chuyển payment từ GitHub sang GitLab, bạn chỉ sửa một dòng trong map, deploy lại, và không service nào phải đổi import.

Một điểm nữa: hãy coi vanity server là hạ tầng quan trọng. Nếu domain hết hạn hoặc server chết, go get với module mới sẽ fail (các version đã cache trên proxy.golang.org thì vẫn tải được, nhưng module private thì không). Mình thường đặt nó sau CDN và có health check riêng.

Cấu hình GOPROXY, GOPRIVATE cho code nội bộ

Với module private, bạn không muốn go gửi tên module lên proxy.golang.org hay sum.golang.org. Cấu hình chuẩn mình dùng cho team (Go 1.24+):

# Không gửi module nội bộ lên proxy và checksum DB công khai
go env -w GOPRIVATE='go.acme.vn/*,gitlab.acme.vn/*'

# Dùng proxy nội bộ (Athens v0.15 hoặc tương đương) trước, rồi mới fallback
go env -w GOPROXY='https://goproxy.acme.vn,https://proxy.golang.org,direct'

# Xác thực git bằng token, không hardcode host vào code
git config --global url."https://oauth2:${GITLAB_TOKEN}@gitlab.acme.vn/".insteadOf "https://gitlab.acme.vn/"

# Kiểm tra lại
go env GOPRIVATE GOPROXY GONOSUMDB
Enter fullscreen mode Exit fullscreen mode

Một internal proxy như Athens còn mang lại lợi ích phụ rất lớn: build không phụ thuộc vào việc GitHub có đang sự cố hay không. Nếu bạn từng thấy CI đỏ cả loạt chỉ vì GitHub rate limit hoặc outage thì sẽ hiểu giá trị của nó.

Còn trong thời gian migrate, directive replace trong go.mod là cứu cánh tạm thời:

replace github.com/acme/payment => go.acme.vn/payment v1.8.0
Enter fullscreen mode Exit fullscreen mode

Nhưng đừng để replace sống lâu: nó chỉ có hiệu lực trong main module, không lan truyền sang người dùng library của bạn.

Đừng để CI và code nghiệp vụ "nói tiếng GitHub"

Module path mới chỉ là một nửa câu chuyện. Coupling còn nằm ở những chỗ ít ai để ý:

graph TD
    A[Go codebase] --> B[Module path]
    A --> C[CI pipeline]
    A --> D[Code gọi GitHub API]
    A --> E[Release tooling]
    B --> F[Vanity domain]
    C --> G[Makefile / scripts thuần]
    D --> H[Interface + adapter]
    E --> I[Config theo môi trường]

CI pipeline: Nếu toàn bộ logic build nằm trong YAML của GitHub Actions với hàng chục action của bên thứ ba, bạn sẽ phải viết lại từ đầu khi chuyển sang GitLab CI hay Woodpecker. Cách mình làm: dồn logic vào Makefile hoặc script bash, file workflow chỉ còn là lớp vỏ mỏng gọi make test, make release. Chạy được local = chạy được ở mọi CI.

Code gọi API của nền tảng: Tool kiểm tra version mới, bot tạo issue, service đọc release notes... thường gọi thẳng api.github.com. Hãy đặt sau một interface:

type ReleaseSource interface {
    Latest(ctx context.Context, project string) (Release, error)
}

// githubSource, gitlabSource, giteaSource cùng implement interface này.
// Chọn implementation qua config, không qua import cứng.
Enter fullscreen mode Exit fullscreen mode

Nghe có vẻ over-engineering, nhưng nó còn giúp viết test dễ hơn nhiều vì bạn mock interface thay vì mock HTTP.

Release tooling: GoReleaser v2 hỗ trợ cả GitHub, GitLab và Gitea; chỉ cần tách phần release: ra để đổi bằng biến môi trường, đừng rải URL repo khắp nơi trong .goreleaser.yaml.

Kết luận

GitHub là một công cụ tuyệt vời, nhưng nó không nên là một phần identity của code bạn viết. Vài việc có thể làm ngay trong tuần này:

  • Project mới: dùng go mod init go.<domain-cong-ty>/<ten> thay vì github.com/.... Chi phí gần như bằng 0 lúc bắt đầu, nhưng rất đắt nếu làm sau.
  • Dựng vanity server: 40 dòng Go như ví dụ trên, đặt sau CDN, theo dõi uptime như hạ tầng production.
  • Chuẩn hóa GOPRIVATE và GOPROXY cho cả team và CI; cân nhắc một internal proxy như Athens để build không chết theo outage của bên thứ ba.
  • Làm mỏng file CI: logic nằm trong Makefile/script, YAML chỉ gọi lệnh.
  • Bọc mọi lời gọi API của nền tảng sau interface; đổi provider bằng config.
  • Project cũ: không cần migrate ngay, nhưng hãy thêm module mới bằng vanity path và dùng replace + comment // Deprecated: để chuyển dần từng phần.

Coupling giống như nợ kỹ thuật: bạn không thấy nó cho đến ngày phải trả, và lúc đó lãi suất luôn cao hơn bạn nghĩ.

Top comments (0)