feat: 004/phase 1

This commit is contained in:
2025-11-14 23:54:50 +08:00
parent 9692219e0f
commit 0d52bae1e8
34 changed files with 1222 additions and 21 deletions

View File

@@ -5,10 +5,13 @@ import (
"path/filepath"
"reflect"
"strconv"
"strings"
"time"
"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
"github.com/any-hub/any-hub/internal/hubmodule"
)
// Load 读取并解析 TOML 配置文件,同时注入默认值与校验逻辑。
@@ -86,6 +89,14 @@ func applyHubDefaults(h *HubConfig) {
if h.CacheTTL.DurationValue() < 0 {
h.CacheTTL = Duration(0)
}
if trimmed := strings.TrimSpace(h.Module); trimmed == "" {
h.Module = hubmodule.DefaultModuleKey()
} else {
h.Module = strings.ToLower(trimmed)
}
if h.ValidationMode == "" {
h.ValidationMode = string(hubmodule.ValidationModeETag)
}
}
func durationDecodeHook() mapstructure.DecodeHookFunc {

View File

@@ -0,0 +1,3 @@
package config
import _ "github.com/any-hub/any-hub/internal/hubmodule/legacy"

View File

@@ -0,0 +1,25 @@
package config
import (
"github.com/any-hub/any-hub/internal/hubmodule"
)
// HubRuntime 将 Hub 配置与模块元数据合并,方便运行时快速取用策略。
type HubRuntime struct {
Config HubConfig
Module hubmodule.ModuleMetadata
CacheStrategy hubmodule.CacheStrategyProfile
}
// BuildHubRuntime 根据 Hub 配置和模块元数据创建运行时描述。
func BuildHubRuntime(cfg HubConfig, meta hubmodule.ModuleMetadata) HubRuntime {
strategy := hubmodule.ResolveStrategy(meta, hubmodule.StrategyOptions{
TTLOverride: cfg.CacheTTL.DurationValue(),
ValidationOverride: hubmodule.ValidationMode(cfg.ValidationMode),
})
return HubRuntime{
Config: cfg,
Module: meta,
CacheStrategy: strategy,
}
}

View File

@@ -67,9 +67,11 @@ type HubConfig struct {
Upstream string `mapstructure:"Upstream"`
Proxy string `mapstructure:"Proxy"`
Type string `mapstructure:"Type"`
Module string `mapstructure:"Module"`
Username string `mapstructure:"Username"`
Password string `mapstructure:"Password"`
CacheTTL Duration `mapstructure:"CacheTTL"`
ValidationMode string `mapstructure:"ValidationMode"`
EnableHeadCheck bool `mapstructure:"EnableHeadCheck"`
}

View File

@@ -6,6 +6,8 @@ import (
"net/url"
"strings"
"time"
"github.com/any-hub/any-hub/internal/hubmodule"
)
var supportedHubTypes = map[string]struct{}{
@@ -74,6 +76,24 @@ func (c *Config) Validate() error {
}
hub.Type = normalizedType
moduleKey := strings.ToLower(strings.TrimSpace(hub.Module))
if moduleKey == "" {
moduleKey = hubmodule.DefaultModuleKey()
}
if _, ok := hubmodule.Resolve(moduleKey); !ok {
return newFieldError(hubField(hub.Name, "Module"), fmt.Sprintf("未注册模块: %s", moduleKey))
}
hub.Module = moduleKey
if hub.ValidationMode != "" {
mode := strings.ToLower(strings.TrimSpace(hub.ValidationMode))
switch mode {
case string(hubmodule.ValidationModeETag), string(hubmodule.ValidationModeLastModified), string(hubmodule.ValidationModeNever):
hub.ValidationMode = mode
default:
return newFieldError(hubField(hub.Name, "ValidationMode"), "仅支持 etag/last-modified/never")
}
}
if (hub.Username == "") != (hub.Password == "") {
return newFieldError(hubField(hub.Name, "Username/Password"), "必须同时提供或同时留空")
}

View File

@@ -0,0 +1,32 @@
# hubmodule
集中定义和实现 Any-Hub 的“代理 + 缓存”模块体系。
## 目录结构
```
internal/hubmodule/
├── doc.go # 包级说明与约束
├── README.md # 本文件
├── registry.go # 模块注册/发现入口(后续任务)
└── <module-key>/ # 各仓类型模块,例如 legacy、npm、docker
```
## 模块约束
- **单一接口**:每个模块需要同时实现代理与缓存接口,避免跨包耦合。
- **注册流程**:在模块 `init()` 中调用 `hubmodule.Register(ModuleMetadata{...})`,注册失败必须 panic 以阻止启动。
- **缓存布局**:一律使用 `StoragePath/<Hub>/<path>.body`,如需附加目录需在 `ModuleMetadata` 中声明迁移策略。
- **配置注入**:模块仅通过依赖注入获取 `HubConfigEntry` 和全局参数,禁止直接读取文件或环境变量。
- **可观测性**:所有模块必须输出 `module_key`、命中/回源状态等日志字段,并在返回错误时附带 Hub 名称。
## 开发流程
1. 复制 `internal/hubmodule/template/`(由 T010 提供)作为起点。
2. 填写模块特有逻辑与缓存策略,并确保包含中文注释解释设计。
3. 在模块目录添加 `module_test.go`,使用 `httptest.Server``t.TempDir()` 复现真实流量。
4. 运行 `make modules-test` 验证模块单元测试。
5. 更新 `config.toml` 中对应 `[[Hub]].Module` 字段,验证集成测试后再提交。
## 术语
- **Module Key**:模块唯一标识(如 `legacy``npm-tarball`)。
- **Cache Strategy Profile**:定义 TTL、验证策略、磁盘布局等策略元数据。
- **Legacy Adapter**:包装当前共享实现,确保迁移期间仍可运行。

View File

@@ -0,0 +1,9 @@
// Package hubmodule 聚合任意仓类型的代理 + 缓存模块,并提供统一的注册入口。
//
// 模块作者需要:
// 1. 在 internal/hubmodule/<module-key>/ 目录下实现代理与缓存接口;
// 2. 通过本包暴露的 Register 函数在 init() 中注册模块元数据;
// 3. 保证缓存写入仍遵循 StoragePath/<Hub>/<path>.body 布局,并补充中文注释说明实现细节。
//
// 该包同时负责提供模块发现、可观测信息以及迁移状态的对外查询能力。
package hubmodule

View File

@@ -0,0 +1,44 @@
package hubmodule
import "time"
// MigrationState 描述模块上线阶段,方便观测端区分 legacy/beta/ga。
type MigrationState string
const (
MigrationStateLegacy MigrationState = "legacy"
MigrationStateBeta MigrationState = "beta"
MigrationStateGA MigrationState = "ga"
)
// ValidationMode 描述缓存再验证的默认策略。
type ValidationMode string
const (
ValidationModeETag ValidationMode = "etag"
ValidationModeLastModified ValidationMode = "last-modified"
ValidationModeNever ValidationMode = "never"
)
// CacheStrategyProfile 描述模块的缓存读写策略及其默认值。
type CacheStrategyProfile struct {
TTLHint time.Duration
ValidationMode ValidationMode
DiskLayout string
RequiresMetadataFile bool
SupportsStreamingWrite bool
}
// ModuleMetadata 记录一个模块的静态信息,供配置校验和诊断端使用。
type ModuleMetadata struct {
Key string
Description string
MigrationState MigrationState
SupportedProtocols []string
CacheStrategy CacheStrategyProfile
}
// DefaultModuleKey 返回内置 legacy 模块的键值。
func DefaultModuleKey() string {
return defaultModuleKey
}

View File

@@ -0,0 +1,20 @@
package legacy
import "github.com/any-hub/any-hub/internal/hubmodule"
// 模块描述:包装当前共享的代理 + 缓存实现,供未迁移的 Hub 使用。
func init() {
hubmodule.MustRegister(hubmodule.ModuleMetadata{
Key: hubmodule.DefaultModuleKey(),
Description: "Legacy proxy + cache implementation bundled with any-hub",
MigrationState: hubmodule.MigrationStateLegacy,
SupportedProtocols: []string{
"docker", "npm", "go", "pypi",
},
CacheStrategy: hubmodule.CacheStrategyProfile{
DiskLayout: ".body",
ValidationMode: hubmodule.ValidationModeETag,
SupportsStreamingWrite: true,
},
})
}

View File

@@ -0,0 +1,117 @@
package hubmodule
import (
"fmt"
"sort"
"strings"
"sync"
)
const defaultModuleKey = "legacy"
var globalRegistry = newRegistry()
type registry struct {
mu sync.RWMutex
modules map[string]ModuleMetadata
}
func newRegistry() *registry {
return &registry{modules: make(map[string]ModuleMetadata)}
}
// Register 将模块元数据加入全局注册表,重复键会返回错误。
func Register(meta ModuleMetadata) error {
return globalRegistry.register(meta)
}
// MustRegister 在注册失败时 panic适合模块 init() 中调用。
func MustRegister(meta ModuleMetadata) {
if err := Register(meta); err != nil {
panic(err)
}
}
// Resolve 返回指定键的模块元数据。
func Resolve(key string) (ModuleMetadata, bool) {
return globalRegistry.resolve(key)
}
// List 返回按键排序的模块元数据列表。
func List() []ModuleMetadata {
return globalRegistry.list()
}
// Keys 返回所有已注册模块的键值,供调试或诊断使用。
func Keys() []string {
items := List()
result := make([]string, len(items))
for i, meta := range items {
result[i] = meta.Key
}
return result
}
func (r *registry) normalizeKey(key string) string {
return strings.ToLower(strings.TrimSpace(key))
}
func (r *registry) register(meta ModuleMetadata) error {
if meta.Key == "" {
return fmt.Errorf("module key is required")
}
key := r.normalizeKey(meta.Key)
if key == "" {
return fmt.Errorf("module key is required")
}
meta.Key = key
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.modules[key]; exists {
return fmt.Errorf("module %s already registered", key)
}
r.modules[key] = meta
return nil
}
func (r *registry) mustRegister(meta ModuleMetadata) {
if err := r.register(meta); err != nil {
panic(err)
}
}
func (r *registry) resolve(key string) (ModuleMetadata, bool) {
if key == "" {
return ModuleMetadata{}, false
}
normalized := r.normalizeKey(key)
r.mu.RLock()
defer r.mu.RUnlock()
meta, ok := r.modules[normalized]
return meta, ok
}
func (r *registry) list() []ModuleMetadata {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.modules) == 0 {
return nil
}
keys := make([]string, 0, len(r.modules))
for key := range r.modules {
keys = append(keys, key)
}
sort.Strings(keys)
result := make([]ModuleMetadata, 0, len(keys))
for _, key := range keys {
result = append(result, r.modules[key])
}
return result
}

View File

@@ -0,0 +1,49 @@
package hubmodule
import "testing"
func replaceRegistry(t *testing.T) func() {
t.Helper()
prev := globalRegistry
globalRegistry = newRegistry()
return func() { globalRegistry = prev }
}
func TestRegisterResolveAndList(t *testing.T) {
cleanup := replaceRegistry(t)
defer cleanup()
if err := Register(ModuleMetadata{Key: "beta", MigrationState: MigrationStateBeta}); err != nil {
t.Fatalf("register beta failed: %v", err)
}
if err := Register(ModuleMetadata{Key: "gamma", MigrationState: MigrationStateGA}); err != nil {
t.Fatalf("register gamma failed: %v", err)
}
if _, ok := Resolve("beta"); !ok {
t.Fatalf("expected beta to resolve")
}
if _, ok := Resolve("BETA"); !ok {
t.Fatalf("resolve should be case-insensitive")
}
list := List()
if len(list) != 2 {
t.Fatalf("list length mismatch: %d", len(list))
}
if list[0].Key != "beta" || list[1].Key != "gamma" {
t.Fatalf("unexpected order: %+v", list)
}
}
func TestRegisterDuplicateFails(t *testing.T) {
cleanup := replaceRegistry(t)
defer cleanup()
if err := Register(ModuleMetadata{Key: "legacy"}); err != nil {
t.Fatalf("first registration should succeed: %v", err)
}
if err := Register(ModuleMetadata{Key: "legacy"}); err == nil {
t.Fatalf("duplicate registration should fail")
}
}

View File

@@ -0,0 +1,21 @@
package hubmodule
import "time"
// StrategyOptions 描述来自 Hub Config 的 override。
type StrategyOptions struct {
TTLOverride time.Duration
ValidationOverride ValidationMode
}
// ResolveStrategy 将模块的默认策略与 hub 级覆盖合并。
func ResolveStrategy(meta ModuleMetadata, opts StrategyOptions) CacheStrategyProfile {
strategy := meta.CacheStrategy
if opts.TTLOverride > 0 {
strategy.TTLHint = opts.TTLOverride
}
if opts.ValidationOverride != "" {
strategy.ValidationMode = opts.ValidationOverride
}
return strategy
}

View File

@@ -0,0 +1,13 @@
package template
import "github.com/any-hub/any-hub/internal/hubmodule"
// Package template 提供编写新模块时可复制的骨架示例。
//
// 使用方式:复制整个目录到 internal/hubmodule/<module-key>/ 并替换字段。
// - 将 TemplateModule 重命名为实际模块类型。
// - 在 init() 中调用 hubmodule.MustRegister注册新的 ModuleMetadata。
// - 在模块目录中实现自定义代理/缓存逻辑,然后在 main 中调用 proxy.RegisterModuleHandler。
//
// 注意:本文件仅示例 metadata 注册写法,不会参与编译。
var _ = hubmodule.ModuleMetadata{}

View File

@@ -11,12 +11,13 @@ func BaseFields(action, configPath string) logrus.Fields {
}
// RequestFields 提供 hub/domain/命中状态字段,供代理请求日志复用。
func RequestFields(hub, domain, hubType, authMode string, cacheHit bool) logrus.Fields {
func RequestFields(hub, domain, hubType, authMode, moduleKey string, cacheHit bool) logrus.Fields {
return logrus.Fields{
"hub": hub,
"domain": domain,
"hub_type": hubType,
"auth_mode": authMode,
"cache_hit": cacheHit,
"hub": hub,
"domain": domain,
"hub_type": hubType,
"auth_mode": authMode,
"cache_hit": cacheHit,
"module_key": moduleKey,
}
}

View File

@@ -0,0 +1,68 @@
package proxy
import (
"strings"
"sync"
"github.com/gofiber/fiber/v3"
"github.com/any-hub/any-hub/internal/server"
)
// Forwarder 根据 HubRoute 的 module_key 选择对应的 ProxyHandler默认回退到构造时注入的 handler。
type Forwarder struct {
defaultHandler server.ProxyHandler
}
// NewForwarder 创建 ForwarderdefaultHandler 不能为空。
func NewForwarder(defaultHandler server.ProxyHandler) *Forwarder {
return &Forwarder{defaultHandler: defaultHandler}
}
var (
moduleHandlers sync.Map
)
// RegisterModuleHandler 将特定 module_key 映射到 ProxyHandler重复注册会覆盖旧值。
func RegisterModuleHandler(key string, handler server.ProxyHandler) {
normalized := normalizeModuleKey(key)
if normalized == "" || handler == nil {
return
}
moduleHandlers.Store(normalized, handler)
}
// Handle 实现 server.ProxyHandler根据 route.ModuleKey 选择 handler。
func (f *Forwarder) Handle(c fiber.Ctx, route *server.HubRoute) error {
handler := f.lookup(route)
if handler == nil {
return fiber.NewError(fiber.StatusInternalServerError, "proxy handler unavailable")
}
return handler.Handle(c, route)
}
func (f *Forwarder) lookup(route *server.HubRoute) server.ProxyHandler {
if route != nil {
if handler := lookupModuleHandler(route.ModuleKey); handler != nil {
return handler
}
}
return f.defaultHandler
}
func lookupModuleHandler(key string) server.ProxyHandler {
normalized := normalizeModuleKey(key)
if normalized == "" {
return nil
}
if value, ok := moduleHandlers.Load(normalized); ok {
if handler, ok := value.(server.ProxyHandler); ok {
return handler
}
}
return nil
}
func normalizeModuleKey(key string) string {
return strings.ToLower(strings.TrimSpace(key))
}

View File

@@ -49,7 +49,10 @@ func (h *Handler) Handle(c fiber.Ctx, route *server.HubRoute) error {
policy := determineCachePolicy(route, locator, c.Method())
if err := ensureProxyHubType(route); err != nil {
h.logger.WithField("hub", route.Config.Name).WithError(err).Error("hub_type_unsupported")
h.logger.WithFields(logrus.Fields{
"hub": route.Config.Name,
"module_key": route.ModuleKey,
}).WithError(err).Error("hub_type_unsupported")
return h.writeError(c, fiber.StatusNotImplemented, "hub_type_unsupported")
}
@@ -67,7 +70,9 @@ func (h *Handler) Handle(c fiber.Ctx, route *server.HubRoute) error {
case errors.Is(err, cache.ErrNotFound):
// miss, continue
default:
h.logger.WithError(err).WithField("hub", route.Config.Name).Warn("cache_get_failed")
h.logger.WithError(err).
WithFields(logrus.Fields{"hub": route.Config.Name, "module_key": route.ModuleKey}).
Warn("cache_get_failed")
}
}
@@ -76,7 +81,9 @@ func (h *Handler) Handle(c fiber.Ctx, route *server.HubRoute) error {
if policy.requireRevalidate {
fresh, err := h.isCacheFresh(c, route, locator, cached.Entry)
if err != nil {
h.logger.WithError(err).WithField("hub", route.Config.Name).Warn("cache_revalidate_failed")
h.logger.WithError(err).
WithFields(logrus.Fields{"hub": route.Config.Name, "module_key": route.ModuleKey}).
Warn("cache_revalidate_failed")
serve = false
} else if !fresh {
serve = false
@@ -316,7 +323,7 @@ func (h *Handler) writeError(c fiber.Ctx, status int, code string) error {
}
func (h *Handler) logResult(route *server.HubRoute, upstream string, requestID string, status int, cacheHit bool, started time.Time, err error) {
fields := logging.RequestFields(route.Config.Name, route.Config.Domain, route.Config.Type, route.Config.AuthMode(), cacheHit)
fields := logging.RequestFields(route.Config.Name, route.Config.Domain, route.Config.Type, route.Config.AuthMode(), route.ModuleKey, cacheHit)
fields["action"] = "proxy"
fields["upstream"] = upstream
fields["upstream_status"] = status
@@ -800,7 +807,7 @@ func isAuthFailure(status int) bool {
}
func (h *Handler) logAuthRetry(route *server.HubRoute, upstream string, requestID string, status int) {
fields := logging.RequestFields(route.Config.Name, route.Config.Domain, route.Config.Type, route.Config.AuthMode(), false)
fields := logging.RequestFields(route.Config.Name, route.Config.Domain, route.Config.Type, route.Config.AuthMode(), route.ModuleKey, false)
fields["action"] = "proxy_retry"
fields["upstream"] = upstream
fields["upstream_status"] = status
@@ -812,7 +819,7 @@ func (h *Handler) logAuthRetry(route *server.HubRoute, upstream string, requestI
}
func (h *Handler) logAuthFailure(route *server.HubRoute, upstream string, requestID string, status int) {
fields := logging.RequestFields(route.Config.Name, route.Config.Domain, route.Config.Type, route.Config.AuthMode(), false)
fields := logging.RequestFields(route.Config.Name, route.Config.Domain, route.Config.Type, route.Config.AuthMode(), route.ModuleKey, false)
fields["action"] = "proxy"
fields["upstream"] = upstream
fields["upstream_status"] = status

View File

@@ -0,0 +1,15 @@
package server
import (
"fmt"
"github.com/any-hub/any-hub/internal/config"
"github.com/any-hub/any-hub/internal/hubmodule"
)
func moduleMetadataForHub(hub config.HubConfig) (hubmodule.ModuleMetadata, error) {
if meta, ok := hubmodule.Resolve(hub.Module); ok {
return meta, nil
}
return hubmodule.ModuleMetadata{}, fmt.Errorf("module %s is not registered", hub.Module)
}

View File

@@ -10,6 +10,7 @@ import (
"time"
"github.com/any-hub/any-hub/internal/config"
"github.com/any-hub/any-hub/internal/hubmodule"
)
// HubRoute 将 Hub 配置与派生属性(如缓存 TTL、解析后的 Upstream/Proxy URL
@@ -24,6 +25,9 @@ type HubRoute struct {
// UpstreamURL/ProxyURL 在构造 Registry 时提前解析完成,便于后续请求快速复用。
UpstreamURL *url.URL
ProxyURL *url.URL
// ModuleKey/Module 记录当前 hub 选用的模块及其元数据,便于日志与观测。
ModuleKey string
Module hubmodule.ModuleMetadata
}
// HubRegistry 提供 Host/Host:port 到 HubRoute 的查询能力,所有 Hub 共享同一个监听端口。
@@ -96,6 +100,11 @@ func (r *HubRegistry) List() []HubRoute {
}
func buildHubRoute(cfg *config.Config, hub config.HubConfig) (*HubRoute, error) {
meta, err := moduleMetadataForHub(hub)
if err != nil {
return nil, fmt.Errorf("hub %s: %w", hub.Name, err)
}
upstreamURL, err := url.Parse(hub.Upstream)
if err != nil {
return nil, fmt.Errorf("invalid upstream for hub %s: %w", hub.Name, err)
@@ -115,6 +124,8 @@ func buildHubRoute(cfg *config.Config, hub config.HubConfig) (*HubRoute, error)
CacheTTL: cfg.EffectiveCacheTTL(hub),
UpstreamURL: upstreamURL,
ProxyURL: proxyURL,
ModuleKey: meta.Key,
Module: meta,
}, nil
}