name: services-layer description: 使用createTaggedError、命名空间导出和Result类型的服务层模式。适用于创建新服务、定义域特定错误或理解服务架构。 metadata: author: epicenter version: ‘1.0’
服务层模式
这个技能文档了如何在Whispering架构中实现服务。服务是纯的、隔离的业务逻辑,没有UI依赖,返回Result<T, E>类型进行错误处理。
何时应用此技能
在以下情况时使用此模式:
- 创建具有域特定错误处理的新服务
- 添加强类型上下文(如HTTP状态码)的错误类型
- 理解服务的组织和导出方式
- 实现平台特定的服务变体(桌面vs网页)
核心架构
服务遵循三层架构:服务 → 查询 → UI
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ UI │ --> │ RPC/查询层 │ --> │ 服务(纯) │
│ 组件 │ │ │ │ │
└─────────────┘ └─────────────┘ └──────────────┘
服务是:
- 纯的:接受显式参数,无隐藏依赖
- 隔离的:不了解UI状态、设置或响应式存储
- 可测试的:易于使用模拟参数进行单元测试
- 一致的:所有返回
Result<T, E>类型进行统一错误处理
使用createTaggedError创建标签化错误
每个服务都使用wellcrafted的createTaggedError定义域特定错误:
import { createTaggedError } from 'wellcrafted/error';
import { Err, Ok, type Result, tryAsync } from 'wellcrafted/result';
// 基本模式 - 创建构造函数和Err助手
export const { MyServiceError, MyServiceErr } =
createTaggedError('MyServiceError');
type MyServiceError = ReturnType<typeof MyServiceError>;
createTaggedError返回什么
createTaggedError('名称')返回一个包含两个属性的对象:
名称Error- 用于创建错误对象的构造函数名称Err- 将错误包装在Err()中以便直接返回的助手
// 这些是等价的:
return Err(MyServiceError({ message: '某物失败' }));
return MyServiceErr({ message: '某物失败' }); // 更短形式
使用.withContext()添加强类型上下文
对于需要结构化元数据(如HTTP状态码)的错误,链式调用.withContext<T>():
type ResponseContext = {
status: number; // HTTP状态码
};
export const { ResponseError, ResponseErr } =
createTaggedError('ResponseError').withContext<ResponseContext>();
// 用法:创建错误时包含上下文
return ResponseErr({
message: '请求失败',
context: { status: 401 }, // TypeScript强制执行此形状
});
代码库中的错误类型示例
// 简单服务错误(最常见)
export const { RecorderServiceError, RecorderServiceErr } = createTaggedError(
'RecorderServiceError',
);
// 具有状态上下文的HTTP错误
export const { ResponseError, ResponseErr } = createTaggedError(
'ResponseError',
).withContext<{ status: number }>();
// 多个相关错误
export const { ConnectionError, ConnectionErr } =
createTaggedError('ConnectionError');
export const { ParseError, ParseErr } = createTaggedError('ParseError');
// 组合成联合类型
export type HttpServiceError = ConnectionError | ResponseError | ParseError;
服务实现模式
基本服务结构
import { createTaggedError, extractErrorMessage } from 'wellcrafted/error';
import { Err, Ok, type Result, tryAsync, trySync } from 'wellcrafted/result';
// 1. 定义域特定错误类型
export const { MyServiceError, MyServiceErr } =
createTaggedError('MyServiceError');
type MyServiceError = ReturnType<typeof MyServiceError>;
// 2. 创建返回服务对象的工厂函数
export function createMyService() {
return {
async doSomething(options: {
param1: string;
param2: number;
}): Promise<Result<OutputType, MyServiceError>> {
// 输入验证
if (!options.param1) {
return MyServiceErr({
message: 'param1是必需的',
});
}
// 使用tryAsync包装风险操作
const { data, error } = await tryAsync({
try: () => riskyAsyncOperation(options),
catch: (error) =>
MyServiceErr({
message: `操作失败:${extractErrorMessage(error)}`,
}),
});
if (error) return Err(error);
return Ok(data);
},
};
}
// 3. 导出“Live”实例(生产单例)
export type MyService = ReturnType<typeof createMyService>;
export const MyServiceLive = createMyService();
真实世界示例:录制服务
// 来自 apps/whispering/src/lib/services/isomorphic/recorder/navigator.ts
export function createNavigatorRecorderService(): RecorderService {
let activeRecording: ActiveRecording | null = null;
return {
getRecorderState: async (): Promise<
Result<WhisperingRecordingState, RecorderServiceError>
> => {
return Ok(activeRecording ? 'RECORDING' : 'IDLE');
},
startRecording: async (
params: NavigatorRecordingParams,
{ sendStatus },
): Promise<Result<DeviceAcquisitionOutcome, RecorderServiceError>> => {
// 验证状态
if (activeRecording) {
return RecorderServiceErr({
message:
'录制已在进行中。请停止当前录制。',
});
}
// 获取流(调用另一个服务)
const { data: streamResult, error: acquireStreamError } =
await getRecordingStream({ selectedDeviceId, sendStatus });
if (acquireStreamError) {
return RecorderServiceErr({
message: acquireStreamError.message,
});
}
// 初始化MediaRecorder
const { data: mediaRecorder, error: recorderError } = trySync({
try: () =>
new MediaRecorder(stream, {
bitsPerSecond: Number(bitrateKbps) * 1000,
}),
catch: (error) =>
RecorderServiceErr({
message: `初始化录制器失败。 ${extractErrorMessage(error)}`,
}),
});
if (recorderError) {
cleanupRecordingStream(stream);
return Err(recorderError);
}
// 存储状态并开始
activeRecording = {
recordingId,
stream,
mediaRecorder,
recordedChunks: [],
};
mediaRecorder.start(TIMESLICE_MS);
return Ok(deviceOutcome);
},
};
}
export const NavigatorRecorderServiceLive = createNavigatorRecorderService();
命名空间导出模式
服务按层次组织并重新导出为命名空间对象:
文件夹结构
services/
├── desktop/ # 仅桌面(Tauri)
│ ├── index.ts # 重新导出为desktopServices
│ ├── command.ts
│ └── ffmpeg.ts
├── isomorphic/ # 跨平台
│ ├── index.ts # 重新导出为services
│ ├── transcription/
│ │ ├── index.ts # 重新导出为transcriptions命名空间
│ │ ├── cloud/
│ │ │ ├── openai.ts
│ │ │ └── groq.ts
│ │ └── local/
│ │ └── whispercpp.ts
│ └── completion/
│ ├── index.ts
│ └── openai.ts
├── types.ts
└── index.ts # 主入口点
索引文件模式
// services/isomorphic/transcription/index.ts
export { OpenaiTranscriptionServiceLive as openai } from './cloud/openai';
export { GroqTranscriptionServiceLive as groq } from './cloud/groq';
export { WhispercppTranscriptionServiceLive as whispercpp } from './local/whispercpp';
// services/isomorphic/index.ts
import * as transcriptions from './transcription';
import * as completions from './completion';
export const services = {
db: DbServiceLive,
sound: PlaySoundServiceLive,
transcriptions, // 命名空间导入
completions, // 命名空间导入
} as const;
// services/index.ts(主入口)
export { services } from './isomorphic';
export { desktopServices } from './desktop';
消费服务
// 在查询层或任何地方
import { services, desktopServices } from '$lib/services';
// 通过命名空间访问
await services.transcriptions.openai.transcribe(blob, options);
await services.transcriptions.groq.transcribe(blob, options);
await services.db.recordings.getAll();
await desktopServices.ffmpeg.compressAudioBlob(blob, options);
平台特定服务
对于每个平台需要不同实现的服务:
定义共享接口
// services/isomorphic/text/types.ts
export type TextService = {
readFromClipboard(): Promise<Result<string | null, TextServiceError>>;
copyToClipboard(text: string): Promise<Result<void, TextServiceError>>;
writeToCursor(text: string): Promise<Result<void, TextServiceError>>;
};
按平台实现
// services/isomorphic/text/desktop.ts
export function createTextServiceDesktop(): TextService {
return {
copyToClipboard: (text) =>
tryAsync({
try: () => writeText(text), // Tauri API
catch: (error) => TextServiceErr({ message: '剪贴板写入失败' }),
}),
};
}
// services/isomorphic/text/web.ts
export function createTextServiceWeb(): TextService {
return {
copyToClipboard: (text) =>
tryAsync({
try: () => navigator.clipboard.writeText(text), // 浏览器API
catch: (error) => TextServiceErr({ message: '剪贴板写入失败' }),
}),
};
}
构建时平台检测
// services/isomorphic/text/index.ts
export const TextServiceLive = window.__TAURI_INTERNALS__
? createTextServiceDesktop()
: createTextServiceWeb();
错误消息最佳实践
编写错误消息时,应:
- 用户友好:用通俗语言解释发生了什么
- 可操作:建议用户可以做什么
- 详细:包含调试的技术细节
// 好的错误消息
return RecorderServiceErr({
message:
'无法连接到所选麦克风。这可能是因为设备已被其他应用程序使用、已断开连接或缺少适当权限。',
});
return MyServiceErr({
message: `解析配置文件失败。请检查${filename}是否包含有效的JSON。`,
});
// 使用extractErrorMessage包含技术细节
return MyServiceErr({
message: `数据库操作失败。${extractErrorMessage(error)}`,
});
关键规则
- 服务从不导入设置 - 将配置作为参数传递
- 服务从不导入UI代码 - 无toast、无通知、无WhisperingError
- 始终返回Result类型 - 从不抛出错误
- 使用trySync/tryAsync - 详见错误处理技能
- 导出工厂+Live实例 - 工厂用于测试,Live用于生产
- 错误命名一致 -
{服务名称}ServiceError模式
参考
- 参见
apps/whispering/src/lib/services/README.md了解架构细节 - 参见
query-layer技能了解如何消费服务 - 参见
error-handling技能了解trySync/tryAsync模式