构建基于OIDC与MySQL的实时特征存储元数据服务及Storybook组件化前端


当团队的机器学习模型从个位数增长到几十个时,特征管理混乱是必然会引爆的第一个问题。最初,特征逻辑散落在各个数据处理脚本和模型训练代码中,不仅重复计算,更严重的是线上线下特征不一致导致的灾难性后果。我们决定构建一个内部的Feature Store,但第一步并非直接处理海量实时数据,而是先建立一个稳固的、可观测的、安全的“元数据控制平面”。

这个控制平面的核心痛点是:

  1. 权限失控:谁可以定义新特征?谁能修改生产环境的特征源?没有身份认证,一切都是空谈。
  2. 元数据孤岛:特征的定义、负责人、数据源、新鲜度要求等信息,必须有唯一的、可信的存储中心。
  3. UI组件的复用与测试:我们需要为特征设计大量可视化组件,例如数据分布直方图、特征新鲜度仪表盘等。这些组件必须能够独立开发、测试和文档化,否则前端开发将陷入泥潭。

我们的初步构想是:一个Go语言编写的后端服务,负责管理存储在MySQL中的特征元数据,并通过OIDC与公司的身份提供商(IdP)集成,实现单点登录和API认证。前端则采用React,所有UI元素都作为独立的、用Emotion样式化的组件,在Storybook中进行开发和展示。

技术选型决策

  • Go后端: 对于这类中间件性质的服务,Go的性能、并发模型和静态编译带来的便捷部署是巨大优势。
  • MySQL: 我们选择MySQL 8.0存储特征元数据,而非NoSQL。原因是特征元数据是高度结构化的,且需要事务保证其定义的一致性。关系型数据库在此场景下更可靠。
  • OpenID Connect (OIDC): 在企业环境中,接入统一的身份认证是硬性要求。OIDC是基于OAuth 2.0的标准化协议,能够直接与Okta、Auth0或自建的IdP集成,省去了自己管理用户凭据的麻烦和安全风险。直接在API网关或服务中间件中验证JWT即可。
  • React + Emotion + Storybook: 这是为了解决前端的工程化问题。Storybook提供了一个隔离的开发环境,让我们可以专注于UI组件逻辑,而不必等待后端API就绪。Emotion作为CSS-in-JS方案,能将样式与组件逻辑封装在一起,天然地实现了组件作用域,避免了全局CSS污染。

后端实现:元数据服务与OIDC集成

1. MySQL元数据表结构

首先是元数据的基石。一个常见的错误是初期设计过于简单。我们的表结构必须考虑到特征的生命周期、版本、所有权和数据源。

-- feature_definitions.sql
-- 存储特征的核心定义信息
CREATE TABLE `feature_definitions` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(255) NOT NULL COMMENT '特征名称,全局唯一,例如 user_7d_login_count',
  `project` VARCHAR(100) NOT NULL COMMENT '所属项目/业务线',
  `description` TEXT NOT NULL COMMENT '特征的详细业务描述',
  `value_type` VARCHAR(50) NOT NULL COMMENT '特征值类型 (e.g., INT64, FLOAT, STRING)',
  `owner_email` VARCHAR(255) NOT NULL COMMENT '特征负责人邮箱',
  `status` ENUM('ACTIVE', 'DEPRECATED', 'ARCHIVED') NOT NULL DEFAULT 'ACTIVE' COMMENT '特征状态',
  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='特征定义表';

-- feature_sources.sql
-- 描述特征的数据来源,一个特征可以有多个数据源(例如在线和离线)
CREATE TABLE `feature_sources` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `feature_id` BIGINT UNSIGNED NOT NULL COMMENT '关联的特征ID',
  `source_type` ENUM('BATCH', 'STREAMING') NOT NULL COMMENT '数据源类型',
  `source_config` JSON NOT NULL COMMENT '数据源的具体配置,例如Kafka topic, Hive table',
  `freshness_sla_seconds` INT UNSIGNED NOT NULL DEFAULT 86400 COMMENT '数据新鲜度SLA(秒)',
  `last_updated_ts` BIGINT COMMENT '数据源最近一次成功更新的时间戳(毫秒)',
  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_feature_id` (`feature_id`),
  CONSTRAINT `fk_feature_id` FOREIGN KEY (`feature_id`) REFERENCES `feature_definitions` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='特征数据源表';

这里的source_config使用JSON类型是一个关键决策,它提供了灵活性以支持未来可能出现的各种数据源,而无需频繁修改表结构。

2. Go服务与OIDC中间件

我们使用gin作为Web框架,并需要一个OIDC token验证的中间件。这里的坑在于,不能简单地解码JWT,必须严格遵循OIDC规范,验证签名、iss (issuer)、aud (audience)以及exp (expiration)。

// main.go
package main

import (
	"context"
	"log"
	"net/http"
	"strings"
	"time"

	"github.com/coreos/go-oidc/v3/oidc"
	"github.com/gin-gonic/gin"
	"golang.org/x/oauth2"
)

// OIDCConfig holds the necessary configuration for OIDC authentication.
type OIDCConfig struct {
	ProviderURL  string
	ClientID     string
	ClientSecret string // Only needed for some flows, not typically for API validation.
	RedirectURL  string // Not needed for pure API service.
}

// AuthMiddleware creates a gin middleware for OIDC token validation.
func AuthMiddleware(config OIDCConfig) gin.HandlerFunc {
	// Initialize the OIDC provider.
	// This will fetch the discovery document (/.well-known/openid-configuration)
	// and set up the verifier with the provider's public keys.
	// In a production environment, you might want to cache this provider instance.
	ctx := context.Background()
	provider, err := oidc.NewProvider(ctx, config.ProviderURL)
	if err != nil {
		log.Fatalf("Failed to create OIDC provider: %v", err)
	}

	// The verifier is configured with the expected Client ID and the provider's keyset.
	verifier := provider.Verifier(&oidc.Config{ClientID: config.ClientID})

	return func(c *gin.Context) {
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is missing"})
			return
		}

		parts := strings.Split(authHeader, " ")
		if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"})
			return
		}
		rawToken := parts[1]

		// Verify the ID token. This checks the signature, expiration, issuer, and audience.
		idToken, err := verifier.Verify(ctx, rawToken)
		if err != nil {
			log.Printf("Token verification failed: %v", err)
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
			return
		}

		// Token is valid. We can extract claims and pass them down the request context.
		var claims struct {
			Email    string   `json:"email"`
			Verified bool     `json:"email_verified"`
			Groups   []string `json:"groups"`
		}
		if err := idToken.Claims(&claims); err != nil {
			log.Printf("Failed to extract claims: %v", err)
			c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Failed to process token claims"})
			return
		}
		
		// In a real project, you would check if the user/groups have the necessary permissions.
		// For now, we just attach the email to the context.
		c.Set("user_email", claims.Email)
		
		log.Printf("Authenticated user: %s", claims.Email)
		c.Next()
	}
}

func main() {
	// Database connection and repository setup would go here...
	// db := database.Connect()
	// featureRepo := repository.NewFeatureRepository(db)
	// featureHandler := handler.NewFeatureHandler(featureRepo)

	router := gin.Default()

	// In a real setup, these would come from environment variables or a config file.
	oidcConfig := OIDCConfig{
		ProviderURL: "https://your-idp.com/auth/realms/your-realm", // e.g., Keycloak, Okta
		ClientID:    "feature-store-api",
	}

	api := router.Group("/api/v1")
	api.Use(AuthMiddleware(oidcConfig))
	{
		// These endpoints are now protected.
		// api.GET("/features", featureHandler.ListFeatures)
		// api.POST("/features", featureHandler.CreateFeature)
	}
    
    // Example protected route
    api.GET("/features", func(c *gin.Context){
        userEmail, _ := c.Get("user_email")
        c.JSON(http.StatusOK, gin.H{
            "message": "Successfully accessed protected features endpoint",
            "user": userEmail,
            "features": []string{"user_7d_login_count", "item_popularity_score"}, // Dummy data
        })
    })

	router.Run(":8080")
}

这个中间件的关键在于oidc.NewProvider,它会自动处理OIDC发现协议,获取jwks_uri等元数据。这比手动管理公钥要健壮得多。同时,我们将解析出的用户信息(如email)放入gin.Context,下游的处理器就可以用它来进行鉴权和记录审计日志。

sequenceDiagram
    participant Client
    participant API_Service
    participant IdP

    Client->>IdP: Authenticate user, request ID Token
    IdP-->>Client: Returns ID Token (JWT)
    
    Client->>API_Service: GET /api/v1/features 
Authorization: Bearer [ID_Token] API_Service->>API_Service: AuthMiddleware intercepts request API_Service->>IdP: Fetches discovery doc & public keys (cached) API_Service->>API_Service: Verifies JWT signature, issuer, audience, expiry alt Token Valid API_Service->>API_Service: Extracts claims (e.g., user email) API_Service->>API_Service: Proceeds to handler API_Service-->>Client: 200 OK with feature data else Token Invalid API_Service-->>Client: 401 Unauthorized end

前端组件化:Storybook与Emotion的实践

前端的目标是创建一个可维护的组件库。我们将专注于一个核心组件:FeatureFreshnessIndicator,它用于可视化一个特征的当前数据新鲜度是否满足SLA。

1. 组件设计与Emotion样式

该组件接收两个props:lastUpdatedTs (上次更新的时间戳) 和 freshnessSlaSeconds (SLA秒数)。它会计算当前是否“新鲜”,并显示不同的颜色和状态。

// src/components/FeatureFreshnessIndicator.tsx
import React from 'react';
import styled from '@emotion/styled';

// Define status colors for reusability and consistency.
const STATUS_COLORS = {
  fresh: '#2e7d32',   // Green
  stale: '#d32f2f',   // Red
  unknown: '#757575', // Gray
};

// Use Emotion's styled-component API.
// The `status` prop will determine the final color.
const IndicatorWrapper = styled.div`
  display: inline-flex;
  align-items: center;
  padding: 4px 12px;
  border-radius: 16px;
  font-family: 'Inter', sans-serif;
  font-size: 14px;
  font-weight: 500;
  color: white;
  background-color: ${(props: { status: 'fresh' | 'stale' | 'unknown' }) =>
    STATUS_COLORS[props.status]};
`;

const StatusDot = styled.span`
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  margin-right: 8px;
  background-color: white;
`;

const TimeText = styled.span`
  opacity: 0.9;
`;

export interface FeatureFreshnessIndicatorProps {
  /** The Unix timestamp (in milliseconds) of the last update. Null if never updated. */
  lastUpdatedTs: number | null;
  /** The Service Level Agreement for freshness, in seconds. */
  freshnessSlaSeconds: number;
}

export const FeatureFreshnessIndicator: React.FC<FeatureFreshnessIndicatorProps> = ({
  lastUpdatedTs,
  freshnessSlaSeconds,
}) => {
  if (lastUpdatedTs === null) {
    return (
      <IndicatorWrapper status="unknown">
        <StatusDot />
        <span>Unknown</span>
      </IndicatorWrapper>
    );
  }

  const now = Date.now();
  const secondsSinceUpdate = (now - lastUpdatedTs) / 1000;
  const isFresh = secondsSinceUpdate <= freshnessSlaSeconds;
  const status = isFresh ? 'fresh' : 'stale';

  // A small utility function to format time difference human-readably.
  const formatTimeAgo = (seconds: number): string => {
    if (seconds < 60) return `${Math.floor(seconds)}s ago`;
    const minutes = Math.floor(seconds / 60);
    if (minutes < 60) return `${minutes}m ago`;
    const hours = Math.floor(minutes / 60);
    if (hours < 24) return `${hours}h ago`;
    const days = Math.floor(hours / 24);
    return `${days}d ago`;
  };

  return (
    <IndicatorWrapper status={status}>
      <StatusDot />
      <span>{status.charAt(0).toUpperCase() + status.slice(1)}</span>
      <TimeText style={{ marginLeft: '8px' }}>
        ({formatTimeAgo(secondsSinceUpdate)})
      </TimeText>
    </IndicatorWrapper>
  );
};

注意,我们将颜色等主题相关的变量提取了出来,并且样式定义直接与组件绑定,这就是Emotion的核心优势。

2. 在Storybook中开发和测试组件

创建对应的Story文件,我们就能在Storybook的UI中看到这个组件的各种状态,而无需运行整个React应用。

// src/components/FeatureFreshnessIndicator.stories.tsx
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { FeatureFreshnessIndicator, FeatureFreshnessIndicatorProps } from './FeatureFreshnessIndicator';

export default {
  title: 'Components/FeatureFreshnessIndicator',
  component: FeatureFreshnessIndicator,
  // Define argTypes for better control in Storybook UI.
  argTypes: {
    lastUpdatedTs: {
      control: 'number',
      description: 'Unix timestamp (ms) of last update',
    },
    freshnessSlaSeconds: {
      control: 'number',
      description: 'SLA in seconds',
    },
  },
} as ComponentMeta<typeof FeatureFreshnessIndicator>;

const Template: ComponentStory<typeof FeatureFreshnessIndicator> = (args) => <FeatureFreshnessIndicator {...args} />;

// A story for the "Fresh" state.
export const Fresh = Template.bind({});
Fresh.args = {
  // Last updated 30 seconds ago
  lastUpdatedTs: Date.now() - 30 * 1000,
  // SLA is 1 hour (3600 seconds)
  freshnessSlaSeconds: 3600,
};

// A story for the "Stale" state.
export const Stale = Template.bind({});
Stale.args = {
  // Last updated 2 hours ago
  lastUpdatedTs: Date.now() - 2 * 3600 * 1000,
  // SLA is 1 hour
  freshnessSlaSeconds: 3600,
};

// A story for a very short SLA.
export const StaleShortSLA = Template.bind({});
StaleShortSLA.args = {
    // Last updated 10 minutes ago
    lastUpdatedTs: Date.now() - 10 * 60 * 1000,
    // SLA is only 5 minutes (300 seconds)
    freshnessSlaSeconds: 300,
};


// A story for the "Unknown" state where data has never been updated.
export const Unknown = Template.bind({});
Unknown.args = {
  lastUpdatedTs: null,
  freshnessSlaSeconds: 86400, // 1 day SLA
};

通过yarn storybook启动后,我们就能得到一个交互式的组件文档。设计师、产品经理和后端工程师都可以访问这个页面,对UI组件的行为达成共识。前端开发者可以在此调试边缘情况(比如lastUpdatedTsnull0),甚至进行视觉回归测试,这极大地提升了开发效率和组件质量。

最终成果与局限性

通过这套架构,我们完成了一个具备企业级身份认证的特征元数据管理服务的原型。后端提供了安全的、结构化的元数据API,前端通过Storybook构建了一套可复用、可测试的UI组件库。开发流程变得清晰:

  1. API定义: 后端定义好API schema。
  2. 组件开发: 前端在Storybook中根据schema模拟数据,独立开发UI组件。
  3. 集成: 将开发好的组件集成到应用页面中,连接真实的、受OIDC保护的后端API。

这个方案并非没有局限性。当前的设计主要解决了元数据的“静态”管理和“准实时”观测。它还存在以下待完善之处:

  1. 实时性: last_updated_ts目前依赖数据源处理任务完成后回调API来更新。对于毫秒级延迟的流式特征,这种HTTP回调模式可能会成为瓶颈。未来的迭代可能会引入一个轻量级的消息队列(如NATS)来接收更新信号。
  2. 可观测性深度: 当前仅展示了新鲜度。一个成熟的系统还需要监控特征的数据分布漂移、空值率等,这对后端的数据分析能力和前端组件的复杂性都提出了更高要求。
  3. 扩展性: source_config虽然是JSON,但其解析和验证逻辑仍在应用代码中。更理想的方式是引入插件化机制,让不同类型的数据源可以作为独立的插件进行注册和管理,这需要对后端架构进行更深层次的重构。

尽管如此,这个坚实的控制平面为我们后续构建高性能的在线特征服务和离线数据处理流程奠定了基础,确保了整个系统的安全、可维护和可演进。


  目录