跳到主内容

v0.10.0 版本说明

EnerOS v0.10.0

发布日期:2024年10月6日 版本代号:Horizon(地平线) Git Tag:v0.10.0 支持状态:内部预览(Internal Preview) Crate 总数:9 测试用例数:1463

版本概述

EnerOS v0.10.0「Horizon」引入了 REST API 接口层,使 EnerOS 能够通过 HTTP 协议对外提供电网分析与控制服务。本版本发布 eneros-api crate,基于 axum Web 框架构建,提供 OpenAPI 3.0 文档自动生成、JWT 认证与授权、API 版本管理三大能力。这是 EnerOS 从「嵌入式运行时」走向「平台化服务」的关键一步——外部系统(SCADA、EMS、DMS、运维门户、移动端 App)现在可以通过标准 HTTP/JSON 接口访问 EnerOS 的拓扑、潮流、约束、Agent 与时序数据能力。

axum 是 Tokio 团队官方维护的 Rust Web 框架,其特点是「无宏、类型安全、与 Tower 中间件生态无缝集成」。EnerOS 选择 axum 而非 actix-web 或 rocket,原因有三:第一,axum 与 EnerOS 已有的 tokio 异步运行时天然兼容,无需引入额外运行时;第二,axum 的 extractor 模式与 Rust 类型系统结合紧密,能够在编译期捕获参数解析错误;第三,axum 的 Tower 中间件生态提供了成熟的认证、限流、日志、追踪组件,避免重复造轮子。v0.10.0 的 API 层完全基于 Tower 中间件链构建,可灵活插拔。

OpenAPI 3.0 文档自动生成通过 utoipa crate 实现。开发者只需在 handler 函数上添加 #[utoipa::path] 宏注解,并在 OpenApi derive 中注册,即可自动生成符合 OpenAPI 3.0 规范的 JSON 文档,并通过 Swagger UI 或 Redoc 提供交互式 API 浏览界面。这一特性大幅降低了 API 文档维护成本——文档与代码同源,永远不会过时。JWT 认证采用 jsonwebtoken crate,支持 HS256/RS256 两种签名算法,令牌包含用户身份、角色、权限声明,与 v0.2.0 的 capability 模型打通——API 层将 JWT 中的权限声明转换为内核 capability,实现端到端的权限传递。

关键数据

指标数值说明
API 端点数42REST 资源
OpenAPI 文档大小38 KB自动生成
请求延迟 P994.2 ms不含业务计算
并发连接数4096单实例
JWT 验证耗时180 μsRS256
支持认证算法2HS256/RS256

新特性

1. REST API 接口层(axum 集成)

// crates/eneros-api/src/app.rs
use axum::{Router, routing::{get, post, put, delete}, middleware, extract::State};
use std::sync::Arc;

/// API 应用
pub struct ApiApp {
    router: Router,
    state: Arc<AppState>,
}

/// 应用共享状态
pub struct AppState {
    pub network: Arc<RwLock<Network>>,
    pub agent_manager: Arc<RwLock<AgentManager>>,
    pub timeseries: Arc<RwLock<TimeSeriesEngine>>,
    pub constraint_engine: Arc<ConstraintEngine>,
}

impl ApiApp {
    pub fn new(state: Arc<AppState>) -> Self {
        let router = Router::new()
            // 拓扑接口
            .route("/api/v1/topology/buses", get(list_buses))
            .route("/api/v1/topology/buses/:id", get(get_bus))
            .route("/api/v1/topology/branches", get(list_branches))
            .route("/api/v1/topology/islands", get(list_islands))
            // 潮流接口
            .route("/api/v1/powerflow/solve", post(solve_powerflow))
            .route("/api/v1/powerflow/results/:id", get(get_powerflow_result))
            // 约束接口
            .route("/api/v1/constraints", get(list_constraints))
            .route("/api/v1/constraints/check", post(check_constraints))
            // Agent 接口
            .route("/api/v1/agents", get(list_agents).post(create_agent))
            .route("/api/v1/agents/:id", get(get_agent).delete(delete_agent))
            .route("/api/v1/agents/:id/messages", post(send_message))
            // 时序接口
            .route("/api/v1/timeseries/query", post(query_timeseries))
            .route("/api/v1/timeseries/write", post(write_timeseries))
            // 健康检查
            .route("/health", get(health_check))
            .route("/ready", get(readiness_check))
            // 中间件链
            .layer(middleware::from_fn(auth_middleware))
            .layer(middleware::from_fn(logging_middleware))
            .layer(middleware::from_fn(rate_limit_middleware))
            .with_state(state);

        Self { router, state }
    }

    pub async fn serve(self, addr: &str) -> EnerOSResult<()> {
        let listener = tokio::net::TcpListener::bind(addr).await?;
        axum::serve(listener, self.router).await?;
        Ok(())
    }
}

2. OpenAPI 文档自动生成

// crates/eneros-api/src/openapi.rs
use utoipa::{OpenApi, ToSchema};
use utoipa::openapi::security::HttpAuthScheme;

#[derive(OpenApi)]
#[openapi(
    paths(
        crate::handlers::topology::list_buses,
        crate::handlers::topology::get_bus,
        crate::handlers::powerflow::solve_powerflow,
        crate::handlers::constraint::check_constraints,
        crate::handlers::agent::list_agents,
        crate::handlers::agent::create_agent,
        crate::handlers::timeseries::query_timeseries,
    ),
    components(schemas(
        Bus, Branch, Generator,
        PowerFlowResult, PowerFlowConfig,
        ConstraintResult, AgentInfo, AgentManifest,
        QueryRequest, QueryResponse,
        ErrorResponse,
    )),
    security(("bearerAuth" = [])),
    info(
        title = "EnerOS API",
        version = "0.10.0",
        description = "EnerOS Power/Energy-Native AgentOS REST API",
        license(name = "MIT"),
    )
)]
pub struct ApiDoc;

impl ApiDoc {
    /// 获取 OpenAPI JSON
    pub fn json() -> String {
        serde_json::to_string_pretty(&Self::openapi()).unwrap()
    }

    /// 提供 Swagger UI
    pub fn swagger_ui() -> axum::Router {
        utoipa_swagger_ui::SwaggerUi::new("/docs/*tail")
            .url("/api-docs/openapi.json", Self::openapi())
            .into()
    }
}

Handler 注解示例:

// crates/eneros-api/src/handlers/topology.rs
use axum::extract::{Path, Query, State};
use axum::Json;
use utoipa::{ToSchema, IntoParams};
use serde::{Deserialize, Serialize};

/// 母线信息
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct BusResponse {
    pub id: u32,
    pub name: String,
    pub voltage_level: f64,
    pub bus_type: String,
    pub base_kv: f64,
}

/// 列出所有母线
#[utoipa::path(
    get,
    path = "/api/v1/topology/buses",
    responses(
        (status = 200, description = "成功", body = [BusResponse]),
        (status = 401, description = "未认证", body = ErrorResponse),
        (status = 500, description = "内部错误", body = ErrorResponse),
    ),
    security(("bearerAuth" = [])),
    tag = "topology"
)]
pub async fn list_buses(
    State(state): State<Arc<AppState>>,
    claims: AuthClaims,
) -> Result<Json<Vec<BusResponse>>, ApiError> {
    let network = state.network.read().await;
    let buses = network.buses().map(|b| BusResponse {
        id: b.id.0,
        name: b.name.clone(),
        voltage_level: b.voltage_level.kv(),
        bus_type: format!("{:?}", b.bus_type),
        base_kv: b.base_kv,
    }).collect();
    Ok(Json(buses))
}

/// 获取单条母线
#[utoipa::path(
    get,
    path = "/api/v1/topology/buses/{id}",
    params(("id" = u32, Path, description = "母线 ID")),
    responses(
        (status = 200, description = "成功", body = BusResponse),
        (status = 404, description = "未找到", body = ErrorResponse),
    ),
    security(("bearerAuth" = [])),
    tag = "topology"
)]
pub async fn get_bus(
    State(state): State<Arc<AppState>>,
    claims: AuthClaims,
    Path(id): Path<u32>,
) -> Result<Json<BusResponse>, ApiError> {
    let network = state.network.read().await;
    let bus = network.bus(BusId(id)).ok_or(ApiError::NotFound)?;
    Ok(Json(BusResponse {
        id: bus.id.0,
        name: bus.name.clone(),
        voltage_level: bus.voltage_level.kv(),
        bus_type: format!("{:?}", bus.bus_type),
        base_kv: bus.base_kv,
    }))
}

3. 认证与授权(JWT)

// crates/eneros-api/src/auth.rs
use axum::extract::Request;
use axum::http::header::AUTHORIZATION;
use axum::middleware::Next;
use axum::response::Response;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};

/// JWT Claims
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthClaims {
    pub sub: String,           // 用户 ID
    pub roles: Vec<String>,    // 角色
    pub permissions: Vec<String>, // 权限
    pub exp: usize,            // 过期时间
    pub iat: usize,            // 签发时间
    pub tenant_id: String,     // 租户 ID
}

/// JWT 配置
pub struct JwtConfig {
    pub secret: Vec<u8>,       // HS256 密钥
    pub private_key: Vec<u8>,  // RS256 私钥
    pub public_key: Vec<u8>,   // RS256 公钥
    pub algorithm: Algorithm,
    pub expiry_secs: u64,
}

#[derive(Debug, Clone, Copy)]
pub enum Algorithm {
    HS256,
    RS256,
}

/// 签发 JWT
pub fn issue_token(config: &JwtConfig, user: &User) -> EnerOSResult<String> {
    let now = chrono::Utc::now();
    let claims = AuthClaims {
        sub: user.id.clone(),
        roles: user.roles.clone(),
        permissions: user.permissions.clone(),
        exp: (now + chrono::Duration::seconds(config.expiry_secs as i64)).timestamp() as usize,
        iat: now.timestamp() as usize,
        tenant_id: user.tenant_id.clone(),
    };
    let header = Header::new(match config.algorithm {
        Algorithm::HS256 => jsonwebtoken::Algorithm::HS256,
        Algorithm::RS256 => jsonwebtoken::Algorithm::RS256,
    });
    let key = match config.algorithm {
        Algorithm::HS256 => EncodingKey::from_secret(&config.secret),
        Algorithm::RS256 => EncodingKey::from_rsa_pem(&config.private_key)?,
    };
    Ok(encode(&header, &claims, &key)?)
}

/// 验证 JWT
pub fn verify_token(config: &JwtConfig, token: &str) -> EnerOSResult<AuthClaims> {
    let key = match config.algorithm {
        Algorithm::HS256 => DecodingKey::from_secret(&config.secret),
        Algorithm::RS256 => DecodingKey::from_rsa_pem(&config.public_key)?,
    };
    let mut validation = Validation::new(match config.algorithm {
        Algorithm::HS256 => jsonwebtoken::Algorithm::HS256,
        Algorithm::RS256 => jsonwebtoken::Algorithm::RS256,
    });
    validation.validate_exp = true;
    let data = decode::<AuthClaims>(token, &key, &validation)?;
    Ok(data.claims)
}

/// 认证中间件
pub async fn auth_middleware(mut req: Request, next: Next) -> Result<Response, ApiError> {
    // 跳过健康检查
    if req.uri().path() == "/health" || req.uri().path() == "/ready" {
        return Ok(next.run(req).await);
    }
    let auth_header = req.headers()
        .get(AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|s| s.strip_prefix("Bearer "))
        .ok_or(ApiError::Unauthorized)?;

    let claims = verify_token(&req.state().jwt_config, auth_header)?;
    req.extensions_mut().insert(claims);
    Ok(next.run(req).await)
}

4. API 版本管理

// crates/eneros-api/src/versioning.rs
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;

/// API 版本
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ApiVersion(pub u16, pub u16);

/// 版本协商中间件
pub async fn version_negotiation(mut req: Request, next: Next) -> Result<Response, ApiError> {
    // 从 URL 路径提取版本(/api/v1/...)
    let version = req.uri().path()
        .split('/')
        .find(|s| s.starts_with('v'))
        .and_then(|s| s[1..].parse::<u16>().ok())
        .map(|major| ApiVersion(major, 0))
        .unwrap_or(ApiVersion(1, 0));

    if version.0 > MAX_API_VERSION.0 {
        return Err(ApiError::UnsupportedVersion(version));
    }

    req.extensions_mut().insert(version);
    Ok(next.run(req).await)
}

/// 版本路由
pub fn versioned_router(state: Arc<AppState>) -> Router {
    Router::new()
        .nest("/api/v1", v1_routes(state.clone()))
        .nest("/api/v2", v2_routes(state))
}

改进

  • eneros-agent:Agent 可通过 API 创建与管理
  • eneros-timeseries:新增批量写入接口
  • 依赖:引入 axumutoipajsonwebtokentower-http
  • CI:新增 API 集成测试(基于 reqwest

Bug 修复

  • 修复 list_buses 在网络为空时返回 500 而非空数组的问题(#145)
  • 修复 auth_middleware 在 Authorization 头缺失时 panic 的问题(#149)
  • 修复 OpenAPI 文档中 BusResponse schema 缺失字段的问题(#153)
  • 修复 JWT 过期后返回 500 而非 401 的问题(#157)

破坏性变更

  • 无破坏性变更:API 层为新增 crate,不影响现有接口

性能提升

端点方法P50P99吞吐
/healthGET0.3 ms0.8 ms18 万/秒
/topology/busesGET1.1 ms2.4 ms4.2 万/秒
/powerflow/solvePOST2.8 ms8.5 ms8500/秒
/timeseries/queryPOST4.5 ms12 ms5500/秒
/agents (创建)POST2.2 ms5.1 ms1.2 万/秒

并发性能(4 核 8GB):

并发连接QPSCPU内存
1004.2 万45%120 MB
5008.5 万78%280 MB
10009.8 万92%420 MB
20009.9 万98%580 MB

API 端点分类统计:

资源端点数说明
拓扑8母线/支路/发电机/电气岛
潮流4求解/结果/历史
约束4列表/检查/告警
Agent10CRUD + 消息
时序6查询/写入/降采样
系统10认证/健康/版本
合计42-

贡献者

贡献者角色提交数
@eneros-foundation架构师22
@axum-expertWeb 框架专家48
@api-designerAPI 设计35
@security-engineer安全工程师41
@openapi-contrib文档自动化18

升级指南

新增依赖

[dependencies]
eneros-api = { version = "0.10", path = "../eneros-api" }
axum = "0.7"
tokio = { version = "1.35", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["trace", "cors"] }
utoipa = { version = "4", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "5", features = ["axum"] }
jsonwebtoken = "9"

启动 API 服务

use eneros_api::{ApiApp, AppState};
use std::sync::Arc;

#[tokio::main]
async fn main() -> EnerOSResult<()> {
    let state = Arc::new(AppState::new(/* ... */));
    let app = ApiApp::new(state);
    println!("EnerOS API 服务启动于 http://0.0.0.0:8080");
    println!("Swagger UI: http://localhost:8080/docs");
    app.serve("0.0.0.0:8080").await?;
    Ok(())
}

获取 JWT 令牌

# 登录获取令牌
curl -X POST http://localhost:8080/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username": "admin", "password": "..."}'

# 响应
# {"token": "eyJ...", "expires_in": 3600}

调用 API

# 查询母线列表
curl http://localhost:8080/api/v1/topology/buses \
  -H "Authorization: Bearer eyJ..."

# 触发潮流计算
curl -X POST http://localhost:8080/api/v1/powerflow/solve \
  -H "Authorization: Bearer eyJ..." \
  -H "Content-Type: application/json" \
  -d '{"method": "newton_raphson", "tolerance": 1e-8}'

# 查询时序数据
curl -X POST http://localhost:8080/api/v1/timeseries/query \
  -H "Authorization: Bearer eyJ..." \
  -H "Content-Type: application/json" \
  -d '{
    "measurement_id": 1001,
    "start_ts": "2024-10-01T00:00:00Z",
    "end_ts": "2024-10-01T01:00:00Z"
  }'

API 版本管理策略

EnerOS API 遵循语义化版本:URL 路径中的 v1 对应主版本号。向后兼容的变更(新增字段、新增端点)不增加版本号;破坏性变更需要新增 v2 路径并保留 v1 至少 12 个月。客户端应通过响应头 X-EnerOS-Deprecation 感知废弃通知。