0

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.
    1. Địa chỉ để go command tìm source code (nếu không qua proxy).
    1. 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):
                                        
                                        ```go
                                        // 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))
                                                                                                                        }
                                                                                                                        ```
                                                                                                                        
                                                                                                                        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+):
                                                                                                                        
                                                                                                                        ```bash
                                                                                                                        # 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
                                                                                                                        ```
                                                                                                                        
                                                                                                                        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
                                                                                                                        ```
                                                                                                                        
                                                                                                                        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 để ý:
                                                                                                                        
                                                                                                                        ```mermaid
                                                                                                                        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:
                                                                                                                                                        
                                                                                                                                                        ```go
                                                                                                                                                        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.
                                                                                                                                                            ```
                                                                                                                                                            
                                                                                                                                                            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ĩ.

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.