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 端点数 | 42 | REST 资源 |
| OpenAPI 文档大小 | 38 KB | 自动生成 |
| 请求延迟 P99 | 4.2 ms | 不含业务计算 |
| 并发连接数 | 4096 | 单实例 |
| JWT 验证耗时 | 180 μs | RS256 |
| 支持认证算法 | 2 | HS256/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:新增批量写入接口- 依赖:引入
axum、utoipa、jsonwebtoken、tower-http - CI:新增 API 集成测试(基于
reqwest)
Bug 修复
- 修复
list_buses在网络为空时返回 500 而非空数组的问题(#145) - 修复
auth_middleware在 Authorization 头缺失时 panic 的问题(#149) - 修复 OpenAPI 文档中
BusResponseschema 缺失字段的问题(#153) - 修复 JWT 过期后返回 500 而非 401 的问题(#157)
破坏性变更
- 无破坏性变更:API 层为新增 crate,不影响现有接口
性能提升
| 端点 | 方法 | P50 | P99 | 吞吐 |
|---|---|---|---|---|
| /health | GET | 0.3 ms | 0.8 ms | 18 万/秒 |
| /topology/buses | GET | 1.1 ms | 2.4 ms | 4.2 万/秒 |
| /powerflow/solve | POST | 2.8 ms | 8.5 ms | 8500/秒 |
| /timeseries/query | POST | 4.5 ms | 12 ms | 5500/秒 |
| /agents (创建) | POST | 2.2 ms | 5.1 ms | 1.2 万/秒 |
并发性能(4 核 8GB):
| 并发连接 | QPS | CPU | 内存 |
|---|---|---|---|
| 100 | 4.2 万 | 45% | 120 MB |
| 500 | 8.5 万 | 78% | 280 MB |
| 1000 | 9.8 万 | 92% | 420 MB |
| 2000 | 9.9 万 | 98% | 580 MB |
API 端点分类统计:
| 资源 | 端点数 | 说明 |
|---|---|---|
| 拓扑 | 8 | 母线/支路/发电机/电气岛 |
| 潮流 | 4 | 求解/结果/历史 |
| 约束 | 4 | 列表/检查/告警 |
| Agent | 10 | CRUD + 消息 |
| 时序 | 6 | 查询/写入/降采样 |
| 系统 | 10 | 认证/健康/版本 |
| 合计 | 42 | - |
贡献者
| 贡献者 | 角色 | 提交数 |
|---|---|---|
| @eneros-foundation | 架构师 | 22 |
| @axum-expert | Web 框架专家 | 48 |
| @api-designer | API 设计 | 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 感知废弃通知。