proper productionize project
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap},
|
||||
error::Error,
|
||||
fmt,
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
domain::{ComponentMessage, LiveEvent, LiveEventKind},
|
||||
overlay::OverlaySettings,
|
||||
};
|
||||
|
||||
pub const DANMAKU_OVERLAY_KIND: &str = "danmaku_overlay";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ComponentError {
|
||||
EmptyKind,
|
||||
AlreadyRegistered(String),
|
||||
NotRegistered(String),
|
||||
InvalidSettings {
|
||||
kind: String,
|
||||
detail: String,
|
||||
},
|
||||
UnsupportedSettingsVersion {
|
||||
kind: String,
|
||||
found: u32,
|
||||
expected: u32,
|
||||
},
|
||||
Projection(String),
|
||||
Handler(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ComponentError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::EmptyKind => formatter.write_str("component kind cannot be empty"),
|
||||
Self::AlreadyRegistered(kind) => {
|
||||
write!(formatter, "component kind `{kind}` is already registered")
|
||||
}
|
||||
Self::NotRegistered(kind) => {
|
||||
write!(formatter, "component kind `{kind}` is not registered")
|
||||
}
|
||||
Self::InvalidSettings { kind, detail } => {
|
||||
write!(
|
||||
formatter,
|
||||
"invalid settings for component `{kind}`: {detail}"
|
||||
)
|
||||
}
|
||||
Self::UnsupportedSettingsVersion {
|
||||
kind,
|
||||
found,
|
||||
expected,
|
||||
} => write!(
|
||||
formatter,
|
||||
"component `{kind}` settings version {found} is unsupported; expected {expected}"
|
||||
),
|
||||
Self::Projection(detail) => write!(formatter, "component projection failed: {detail}"),
|
||||
Self::Handler(detail) => write!(formatter, "component event handler failed: {detail}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ComponentError {}
|
||||
|
||||
/// Persisted component instance. `owner_id` is trusted tenancy context and is
|
||||
/// omitted from public serialization.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ComponentInstance {
|
||||
pub id: Uuid,
|
||||
#[serde(skip_serializing)]
|
||||
pub owner_id: Uuid,
|
||||
pub source_id: Uuid,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
pub settings_version: u32,
|
||||
pub settings: Value,
|
||||
}
|
||||
|
||||
impl ComponentInstance {
|
||||
pub fn new(
|
||||
owner_id: Uuid,
|
||||
source_id: Uuid,
|
||||
kind: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
settings_version: u32,
|
||||
settings: Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
owner_id,
|
||||
source_id,
|
||||
kind: kind.into(),
|
||||
name: name.into(),
|
||||
enabled: true,
|
||||
settings_version,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct EventSubscription {
|
||||
kinds: BTreeSet<LiveEventKind>,
|
||||
}
|
||||
|
||||
impl EventSubscription {
|
||||
pub fn new(kinds: impl IntoIterator<Item = LiveEventKind>) -> Self {
|
||||
Self {
|
||||
kinds: kinds.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches(&self, event: &LiveEvent) -> bool {
|
||||
self.kinds.contains(&event.kind())
|
||||
}
|
||||
|
||||
pub fn contains(&self, kind: LiveEventKind) -> bool {
|
||||
self.kinds.contains(&kind)
|
||||
}
|
||||
|
||||
pub fn kinds(&self) -> impl Iterator<Item = LiveEventKind> + '_ {
|
||||
self.kinds.iter().copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// Static behavior and settings contract for one component kind.
|
||||
pub trait ComponentDefinition: Send + Sync {
|
||||
fn kind(&self) -> &'static str;
|
||||
fn settings_version(&self) -> u32;
|
||||
fn default_settings(&self) -> Value;
|
||||
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError>;
|
||||
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError>;
|
||||
|
||||
/// Override when a component changes its settings schema. Keeping migration
|
||||
/// here allows old instances to be upgraded without teaching the router
|
||||
/// about component-specific fields.
|
||||
fn migrate_settings(
|
||||
&self,
|
||||
from_version: u32,
|
||||
settings: Value,
|
||||
) -> Result<Value, ComponentError> {
|
||||
if from_version == self.settings_version() {
|
||||
Ok(settings)
|
||||
} else {
|
||||
Err(ComponentError::UnsupportedSettingsVersion {
|
||||
kind: self.kind().to_owned(),
|
||||
found: from_version,
|
||||
expected: self.settings_version(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A passive, side-effect-free transformation for a browser-facing component.
|
||||
/// It may filter or reshape an event, but must not write business data.
|
||||
pub trait EventProjection: Send + Sync {
|
||||
fn project(
|
||||
&self,
|
||||
component: &ComponentInstance,
|
||||
event: &LiveEvent,
|
||||
) -> Result<Option<ComponentMessage>, ComponentError>;
|
||||
}
|
||||
|
||||
/// Future returned by a durable business handler without requiring an
|
||||
/// `async-trait` dependency.
|
||||
pub type HandlerFuture<'a> = Pin<Box<dyn Future<Output = Result<(), ComponentError>> + Send + 'a>>;
|
||||
|
||||
/// An active handler may perform durable side effects (for example recording a
|
||||
/// song request). It runs independently of WebSocket receiver count and should
|
||||
/// implement idempotency in its persistence layer.
|
||||
pub trait EventHandler: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
fn accepts(&self, _component: &ComponentInstance, _event: &LiveEvent) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn handle<'a>(
|
||||
&'a self,
|
||||
component: &'a ComponentInstance,
|
||||
event: Arc<LiveEvent>,
|
||||
) -> HandlerFuture<'a>;
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PassthroughProjection;
|
||||
|
||||
impl EventProjection for PassthroughProjection {
|
||||
fn project(
|
||||
&self,
|
||||
component: &ComponentInstance,
|
||||
event: &LiveEvent,
|
||||
) -> Result<Option<ComponentMessage>, ComponentError> {
|
||||
ComponentMessage::from_live_event(component.id, event)
|
||||
.map(Some)
|
||||
.map_err(|error| ComponentError::Projection(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DanmakuOverlayDefinition;
|
||||
|
||||
impl DanmakuOverlayDefinition {
|
||||
fn parse(&self, settings: Value) -> Result<OverlaySettings, ComponentError> {
|
||||
serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings {
|
||||
kind: DANMAKU_OVERLAY_KIND.to_owned(),
|
||||
detail: error.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ComponentDefinition for DanmakuOverlayDefinition {
|
||||
fn kind(&self) -> &'static str {
|
||||
DANMAKU_OVERLAY_KIND
|
||||
}
|
||||
|
||||
fn settings_version(&self) -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_settings(&self) -> Value {
|
||||
serde_json::to_value(OverlaySettings::default())
|
||||
.expect("OverlaySettings is always JSON serializable")
|
||||
}
|
||||
|
||||
fn validate_settings(&self, settings: Value) -> Result<Value, ComponentError> {
|
||||
let settings = self.parse(settings)?.sanitize();
|
||||
serde_json::to_value(settings).map_err(|error| ComponentError::InvalidSettings {
|
||||
kind: DANMAKU_OVERLAY_KIND.to_owned(),
|
||||
detail: error.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn subscriptions(&self, settings: &Value) -> Result<EventSubscription, ComponentError> {
|
||||
let settings = self.parse(settings.clone())?;
|
||||
let mut kinds = Vec::with_capacity(9);
|
||||
if settings.show_danmaku {
|
||||
kinds.push(LiveEventKind::Danmaku);
|
||||
}
|
||||
if settings.show_enter {
|
||||
kinds.push(LiveEventKind::Enter);
|
||||
}
|
||||
if settings.show_gift {
|
||||
kinds.extend([LiveEventKind::Gift, LiveEventKind::GiftCombo]);
|
||||
}
|
||||
if settings.show_superchat {
|
||||
kinds.push(LiveEventKind::SuperChat);
|
||||
}
|
||||
if settings.show_guard {
|
||||
kinds.push(LiveEventKind::GuardPurchase);
|
||||
}
|
||||
if settings.show_like {
|
||||
kinds.push(LiveEventKind::Like);
|
||||
}
|
||||
if settings.show_share {
|
||||
kinds.push(LiveEventKind::Share);
|
||||
}
|
||||
Ok(EventSubscription::new(kinds))
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable routing snapshot returned by the registry. All contained trait
|
||||
/// objects are `Arc`, so routing never holds the registry lock across awaits.
|
||||
#[derive(Clone)]
|
||||
pub struct ComponentRuntime {
|
||||
definition: Arc<dyn ComponentDefinition>,
|
||||
projection: Arc<dyn EventProjection>,
|
||||
handlers: Vec<Arc<dyn EventHandler>>,
|
||||
}
|
||||
|
||||
impl ComponentRuntime {
|
||||
pub fn kind(&self) -> &'static str {
|
||||
self.definition.kind()
|
||||
}
|
||||
|
||||
pub fn definition(&self) -> Arc<dyn ComponentDefinition> {
|
||||
self.definition.clone()
|
||||
}
|
||||
|
||||
pub fn validated_settings(
|
||||
&self,
|
||||
instance: &ComponentInstance,
|
||||
) -> Result<Value, ComponentError> {
|
||||
let settings = self
|
||||
.definition
|
||||
.migrate_settings(instance.settings_version, instance.settings.clone())?;
|
||||
self.definition.validate_settings(settings)
|
||||
}
|
||||
|
||||
pub fn subscriptions(
|
||||
&self,
|
||||
instance: &ComponentInstance,
|
||||
) -> Result<EventSubscription, ComponentError> {
|
||||
let settings = self.validated_settings(instance)?;
|
||||
self.definition.subscriptions(&settings)
|
||||
}
|
||||
|
||||
pub fn project(
|
||||
&self,
|
||||
instance: &ComponentInstance,
|
||||
event: &LiveEvent,
|
||||
) -> Result<Option<ComponentMessage>, ComponentError> {
|
||||
self.projection.project(instance, event)
|
||||
}
|
||||
|
||||
pub fn handlers(&self) -> Vec<Arc<dyn EventHandler>> {
|
||||
self.handlers.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ComponentRegistry {
|
||||
entries: Arc<RwLock<HashMap<String, ComponentRuntime>>>,
|
||||
}
|
||||
|
||||
impl ComponentRegistry {
|
||||
/// Create an empty registry for tests or applications that select their own
|
||||
/// component modules.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry used by the current application. Future modules can be added by
|
||||
/// calling `register` during bootstrap.
|
||||
pub fn with_builtin_components() -> Self {
|
||||
let registry = Self::new();
|
||||
registry
|
||||
.register(
|
||||
Arc::new(DanmakuOverlayDefinition),
|
||||
Arc::new(PassthroughProjection),
|
||||
)
|
||||
.expect("built-in component kinds are unique");
|
||||
registry
|
||||
}
|
||||
|
||||
pub fn register(
|
||||
&self,
|
||||
definition: Arc<dyn ComponentDefinition>,
|
||||
projection: Arc<dyn EventProjection>,
|
||||
) -> Result<(), ComponentError> {
|
||||
let kind = definition.kind().trim();
|
||||
if kind.is_empty() {
|
||||
return Err(ComponentError::EmptyKind);
|
||||
}
|
||||
let mut entries = self
|
||||
.entries
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if entries.contains_key(kind) {
|
||||
return Err(ComponentError::AlreadyRegistered(kind.to_owned()));
|
||||
}
|
||||
entries.insert(
|
||||
kind.to_owned(),
|
||||
ComponentRuntime {
|
||||
definition,
|
||||
projection,
|
||||
handlers: Vec::new(),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_handler(
|
||||
&self,
|
||||
kind: &str,
|
||||
handler: Arc<dyn EventHandler>,
|
||||
) -> Result<(), ComponentError> {
|
||||
let mut entries = self
|
||||
.entries
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let runtime = entries
|
||||
.get_mut(kind)
|
||||
.ok_or_else(|| ComponentError::NotRegistered(kind.to_owned()))?;
|
||||
runtime.handlers.push(handler);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn runtime(&self, kind: &str) -> Result<ComponentRuntime, ComponentError> {
|
||||
self.entries
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.get(kind)
|
||||
.cloned()
|
||||
.ok_or_else(|| ComponentError::NotRegistered(kind.to_owned()))
|
||||
}
|
||||
|
||||
pub fn validate_settings(
|
||||
&self,
|
||||
kind: &str,
|
||||
from_version: u32,
|
||||
settings: Value,
|
||||
) -> Result<Value, ComponentError> {
|
||||
let runtime = self.runtime(kind)?;
|
||||
let settings = runtime
|
||||
.definition
|
||||
.migrate_settings(from_version, settings)?;
|
||||
runtime.definition.validate_settings(settings)
|
||||
}
|
||||
|
||||
pub fn kinds(&self) -> Vec<String> {
|
||||
let mut kinds: Vec<_> = self
|
||||
.entries
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
kinds.sort();
|
||||
kinds
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ComponentRegistry {
|
||||
fn default() -> Self {
|
||||
Self::with_builtin_components()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builtin_overlay_settings_are_sanitized_and_define_subscriptions() {
|
||||
let registry = ComponentRegistry::default();
|
||||
let mut settings = serde_json::to_value(OverlaySettings::default()).unwrap();
|
||||
settings["maxVisible"] = Value::from(250);
|
||||
settings["showGift"] = Value::Bool(false);
|
||||
settings["showLike"] = Value::Bool(true);
|
||||
|
||||
let validated = registry
|
||||
.validate_settings(DANMAKU_OVERLAY_KIND, 1, settings)
|
||||
.unwrap();
|
||||
assert_eq!(validated["maxVisible"], 12);
|
||||
|
||||
let instance = ComponentInstance::new(
|
||||
Uuid::new_v4(),
|
||||
Uuid::new_v4(),
|
||||
DANMAKU_OVERLAY_KIND,
|
||||
"弹幕姬",
|
||||
1,
|
||||
validated,
|
||||
);
|
||||
let subscriptions = registry
|
||||
.runtime(DANMAKU_OVERLAY_KIND)
|
||||
.unwrap()
|
||||
.subscriptions(&instance)
|
||||
.unwrap();
|
||||
assert!(subscriptions.contains(LiveEventKind::Danmaku));
|
||||
assert!(subscriptions.contains(LiveEventKind::Like));
|
||||
assert!(!subscriptions.contains(LiveEventKind::Gift));
|
||||
assert!(!subscriptions.contains(LiveEventKind::GiftCombo));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_component_kinds_are_rejected() {
|
||||
let registry = ComponentRegistry::default();
|
||||
let result = registry.register(
|
||||
Arc::new(DanmakuOverlayDefinition),
|
||||
Arc::new(PassthroughProjection),
|
||||
);
|
||||
assert!(matches!(result, Err(ComponentError::AlreadyRegistered(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_settings_versions_fail_closed() {
|
||||
let registry = ComponentRegistry::default();
|
||||
let result = registry.validate_settings(
|
||||
DANMAKU_OVERLAY_KIND,
|
||||
99,
|
||||
serde_json::to_value(OverlaySettings::default()).unwrap(),
|
||||
);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ComponentError::UnsupportedSettingsVersion { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user