403 lines
11 KiB
Markdown
403 lines
11 KiB
Markdown
# Rust Design
|
||
|
||
## 1. Назначение документа
|
||
|
||
Этот документ фиксирует, как проектировать поведение в Rust-коде:
|
||
|
||
- какие методы допустимы на `struct` и `enum`;
|
||
- что должно жить в `impl`;
|
||
- что должно быть вынесено в `trait`;
|
||
- что должно быть оформлено как `service` или `use case`.
|
||
|
||
Главная цель документа - не допустить появления `god-struct`, когда одна сущность одновременно:
|
||
|
||
- хранит данные;
|
||
- валидирует себя целиком;
|
||
- ходит в БД;
|
||
- дергает HTTP;
|
||
- строит mapping;
|
||
- управляет publish flow;
|
||
- содержит половину бизнес-логики проекта.
|
||
|
||
## 2. Базовое правило
|
||
|
||
В Rust нужно разделять:
|
||
|
||
- `data model`
|
||
- `domain behavior`
|
||
- `integration contracts`
|
||
- `application services`
|
||
|
||
То есть:
|
||
|
||
- `struct` и `enum` хранят состояние;
|
||
- `impl` на них содержит только локально связанное поведение;
|
||
- `trait` задает внешний контракт;
|
||
- `service` и `use case` координируют несколько сущностей и внешние зависимости.
|
||
|
||
## 3. Что допустимо держать в `impl` на структурах
|
||
|
||
На `impl` допустимы только методы, которые:
|
||
|
||
- опираются только на внутреннее состояние структуры;
|
||
- не требуют инфраструктурных зависимостей;
|
||
- не ходят в БД;
|
||
- не выполняют сетевые вызовы;
|
||
- не меняют чужие aggregate boundaries.
|
||
|
||
Подходящие примеры:
|
||
|
||
- `Operation::is_published()`
|
||
- `Operation::supports_protocol(protocol)`
|
||
- `Operation::tool_name()`
|
||
- `MappingRule::is_required()`
|
||
- `GeneratedDraft::is_available()`
|
||
- `Schema::field(path)`
|
||
- `Protocol::as_str()`
|
||
|
||
Неподходящие примеры:
|
||
|
||
- `Operation::save(db)`
|
||
- `Operation::publish(repo, runtime, cache)`
|
||
- `Operation::call_external_api()`
|
||
- `Operation::load_descriptor()`
|
||
|
||
## 4. Какие методы должны жить на ключевых доменных структурах
|
||
|
||
### 4.1. `Operation`
|
||
|
||
Допустимые методы:
|
||
|
||
- `fn tool_name(&self) -> &str`
|
||
- `fn is_draft(&self) -> bool`
|
||
- `fn is_published(&self) -> bool`
|
||
- `fn protocol(&self) -> &Protocol`
|
||
- `fn auth_profile_ref(&self) -> Option<&str>`
|
||
- `fn can_be_published(&self) -> bool`
|
||
|
||
Что не должно жить здесь:
|
||
|
||
- создание новой версии;
|
||
- publish;
|
||
- YAML import/export;
|
||
- DB persistence;
|
||
- runtime execution;
|
||
- adapter dispatch.
|
||
|
||
### 4.2. `Target`
|
||
|
||
Допустимые методы:
|
||
|
||
- `fn kind(&self) -> Protocol`
|
||
- `fn summary(&self) -> String`
|
||
|
||
Что не должно жить здесь:
|
||
|
||
- реальный вызов REST/GraphQL/gRPC;
|
||
- загрузка descriptor set;
|
||
- introspection;
|
||
- network logic.
|
||
|
||
### 4.3. `Schema`
|
||
|
||
Допустимые методы:
|
||
|
||
- `fn is_object(&self) -> bool`
|
||
- `fn field(&self, name: &str) -> Option<&SchemaField>`
|
||
- `fn has_required_fields(&self) -> bool`
|
||
- `fn validate_shape(&self, value: &serde_json::Value) -> Result<(), SchemaError>`
|
||
|
||
Что не должно жить здесь:
|
||
|
||
- UI rendering logic;
|
||
- DB serialization logic;
|
||
- adapter-specific request assembly.
|
||
|
||
### 4.4. `MappingSet` и `MappingRule`
|
||
|
||
Допустимые методы:
|
||
|
||
- `fn is_empty(&self) -> bool`
|
||
- `fn validate_paths(&self) -> Result<(), MappingError>`
|
||
- `fn target_context(&self) -> MappingTargetContext`
|
||
|
||
Что не должно жить здесь:
|
||
|
||
- protocol adapter branching;
|
||
- network execution;
|
||
- persistence;
|
||
- доступ к registry.
|
||
|
||
### 4.5. `ExecutionConfig`
|
||
|
||
Допустимые методы:
|
||
|
||
- `fn timeout(&self) -> Duration`
|
||
- `fn has_auth(&self) -> bool`
|
||
- `fn protocol_options(&self) -> Option<&ProtocolOptions>`
|
||
|
||
Что не должно жить здесь:
|
||
|
||
- secret resolution;
|
||
- создание HTTP headers из env;
|
||
- динамическое чтение конфигов приложения.
|
||
|
||
## 5. Что нужно выносить в `trait`
|
||
|
||
`Trait` нужен там, где появляется внешний контракт, который имеет несколько реализаций или зависит от инфраструктуры.
|
||
|
||
Правильные кандидаты:
|
||
|
||
- `OperationRepository`
|
||
- `PublishedOperationRepository`
|
||
- `ProtocolAdapter`
|
||
- `SecretResolver`
|
||
- `DescriptorStore`
|
||
- `ArtifactStore`
|
||
- `YamlCodec`
|
||
- `DraftGenerator`
|
||
|
||
### Пример
|
||
|
||
```rust
|
||
pub trait OperationRepository {
|
||
async fn get(&self, id: &OperationId) -> Result<OperationRecord, RepoError>;
|
||
async fn create_version(&self, cmd: CreateVersion) -> Result<OperationVersionRef, RepoError>;
|
||
async fn publish(&self, id: &OperationId, version: u32) -> Result<(), RepoError>;
|
||
}
|
||
```
|
||
|
||
Почему это `trait`, а не метод на `Operation`:
|
||
|
||
- потому что операция сама не должна знать, как она хранится;
|
||
- потому что хранение - инфраструктурная зависимость;
|
||
- потому что это boundary между доменом и storage.
|
||
|
||
## 6. Что нужно выносить в service/use-case слой
|
||
|
||
Если логика:
|
||
|
||
- координирует несколько сущностей;
|
||
- использует `trait`-зависимости;
|
||
- меняет состояние нескольких aggregate boundaries;
|
||
- имеет бизнес-шаги;
|
||
|
||
то это `service`, а не `impl` на структуре.
|
||
|
||
Кандидаты:
|
||
|
||
- `CreateOperationService`
|
||
- `CreateOperationVersionService`
|
||
- `PublishOperationService`
|
||
- `GenerateDraftService`
|
||
- `TestOperationService`
|
||
- `ImportOperationYamlService`
|
||
- `ExportOperationYamlService`
|
||
- `ListPublishedToolsService`
|
||
- `OperationExecutor`
|
||
|
||
## 7. Рекомендуемое распределение поведения
|
||
|
||
### Domain `impl`
|
||
|
||
Хранит:
|
||
|
||
- локальную валидацию;
|
||
- derived methods;
|
||
- простые status checks;
|
||
- инварианты одной сущности.
|
||
|
||
### `trait`
|
||
|
||
Хранит:
|
||
|
||
- внешние контракты;
|
||
- infrastructure boundaries;
|
||
- replaceable dependencies.
|
||
|
||
### `service`
|
||
|
||
Хранит:
|
||
|
||
- orchestration;
|
||
- use case sequence;
|
||
- transaction boundaries;
|
||
- вызовы нескольких зависимостей.
|
||
|
||
## 8. Пример правильного разделения
|
||
|
||
### Плохо
|
||
|
||
```rust
|
||
impl Operation {
|
||
pub async fn publish(
|
||
&mut self,
|
||
repo: &SqlOperationRepository,
|
||
cache: &RuntimeCache,
|
||
secret_resolver: &EnvSecretResolver,
|
||
) -> Result<(), Error> {
|
||
self.validate()?;
|
||
repo.save(self).await?;
|
||
cache.reload().await?;
|
||
let _ = secret_resolver.resolve(...)?;
|
||
self.status = Status::Published;
|
||
Ok(())
|
||
}
|
||
}
|
||
```
|
||
|
||
Почему плохо:
|
||
|
||
- доменная сущность знает про SQL;
|
||
- знает про кэш;
|
||
- знает про secret resolver;
|
||
- меняет себя и внешний мир одновременно;
|
||
- содержит orchestration.
|
||
|
||
### Правильно
|
||
|
||
```rust
|
||
impl Operation {
|
||
pub fn can_be_published(&self) -> bool {
|
||
matches!(self.status, Status::Draft | Status::Testing)
|
||
&& !self.input_mapping.rules.is_empty()
|
||
&& !self.output_mapping.rules.is_empty()
|
||
}
|
||
}
|
||
|
||
pub struct PublishOperationService<R> {
|
||
repo: R,
|
||
}
|
||
|
||
impl<R> PublishOperationService<R>
|
||
where
|
||
R: OperationRepository,
|
||
{
|
||
pub async fn execute(
|
||
&self,
|
||
operation_id: &OperationId,
|
||
version: u32,
|
||
) -> Result<(), PublishError> {
|
||
let op = self.repo.get_version(operation_id, version).await?;
|
||
if !op.can_be_published() {
|
||
return Err(PublishError::InvalidState);
|
||
}
|
||
self.repo.publish(operation_id, version).await
|
||
}
|
||
}
|
||
```
|
||
|
||
## 9. Признаки `god-struct`
|
||
|
||
Если у структуры:
|
||
|
||
- слишком много полей из разных bounded contexts;
|
||
- методы и на schema, и на DB, и на adapters, и на YAML;
|
||
- методы с кучей зависимостей в аргументах;
|
||
- методы длиннее, чем небольшой локальный инвариант;
|
||
- много `match protocol` прямо внутри доменной модели;
|
||
|
||
то это уже `god-struct`.
|
||
|
||
Особенно опасные кандидаты:
|
||
|
||
- `Operation`
|
||
- `AppState`
|
||
- `OperationExecutor`
|
||
- `AdminService`
|
||
- `ProtocolAdapter`
|
||
|
||
## 10. Как не допустить `god-struct`
|
||
|
||
### 10.1. Для `Operation`
|
||
|
||
Не добавлять туда:
|
||
|
||
- repo methods;
|
||
- transport methods;
|
||
- import/export;
|
||
- publish flow;
|
||
- sample upload handling.
|
||
|
||
### 10.2. Для `AppState`
|
||
|
||
Не складывать все зависимости в один плоский объект на 20 полей.
|
||
|
||
Лучше:
|
||
|
||
- `RegistryServices`
|
||
- `RuntimeServices`
|
||
- `ArtifactServices`
|
||
- `AuthServices`
|
||
|
||
### 10.3. Для `OperationExecutor`
|
||
|
||
Он может быть orchestration root, но не должен становиться монолитом.
|
||
|
||
Нужно выделять:
|
||
|
||
- `InputPrepare`
|
||
- `AdapterDispatch`
|
||
- `OutputFinalize`
|
||
- `ExecutionContextFactory`
|
||
|
||
## 11. Рекомендуемые `impl`-блоки по проекту
|
||
|
||
### В `mcpaas-core`
|
||
|
||
- маленькие `impl` на domain types;
|
||
- status helpers;
|
||
- derived metadata methods.
|
||
|
||
### В `mcpaas-schema`
|
||
|
||
- schema validation;
|
||
- field traversal;
|
||
- shape helpers.
|
||
|
||
### В `mcpaas-mapping`
|
||
|
||
- JSONPath validation;
|
||
- mapping rule helpers;
|
||
- execution helpers.
|
||
|
||
### В `mcpaas-proto`
|
||
|
||
- metadata conversion helpers;
|
||
- descriptor lookup helpers.
|
||
|
||
### В `mcpaas-registry`
|
||
|
||
- service methods, а не методы на доменных структурах;
|
||
- repository implementations.
|
||
|
||
### В `mcpaas-runtime`
|
||
|
||
- orchestration services;
|
||
- adapter dispatch;
|
||
- runtime context management.
|
||
|
||
## 12. Что лучше описывать не как методы структур
|
||
|
||
Следующие вещи лучше описывать отдельными сервисами даже если технически их можно записать как `impl`:
|
||
|
||
- `publish`
|
||
- `create_version`
|
||
- `import_yaml`
|
||
- `export_yaml`
|
||
- `generate_draft`
|
||
- `test_run`
|
||
- `reload_published_tools`
|
||
|
||
## 13. Практический итог
|
||
|
||
Для этого проекта хорошее правило такое:
|
||
|
||
- `struct` знает только себя;
|
||
- `trait` знает границу;
|
||
- `service` знает сценарий;
|
||
- `adapter` знает протокол;
|
||
- `repository` знает storage.
|
||
|
||
Если придерживаться этой схемы, то Rust-код останется модульным, а `Operation` и связанные типы не превратятся в `god-struct` с разнородной логикой.
|