服务层模式Skill services-layer

这个技能文档介绍了在Whispering架构中实现服务层的模式,包括使用createTaggedError创建标签化错误、命名空间导出和Result类型进行统一错误处理。适用于软件开发中创建新服务、定义域特定错误和理解服务架构,提升代码可维护性和测试性。关键词:服务层、架构设计、错误处理、TypeScript、Result类型、createTaggedError、命名空间导出、软件开发。

架构设计 0 次安装 0 次浏览 更新于 3/20/2026

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('名称')返回一个包含两个属性的对象:

  1. 名称Error - 用于创建错误对象的构造函数
  2. 名称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)}`,
});

关键规则

  1. 服务从不导入设置 - 将配置作为参数传递
  2. 服务从不导入UI代码 - 无toast、无通知、无WhisperingError
  3. 始终返回Result类型 - 从不抛出错误
  4. 使用trySync/tryAsync - 详见错误处理技能
  5. 导出工厂+Live实例 - 工厂用于测试,Live用于生产
  6. 错误命名一致 - {服务名称}ServiceError模式

参考

  • 参见apps/whispering/src/lib/services/README.md了解架构细节
  • 参见query-layer技能了解如何消费服务
  • 参见error-handling技能了解trySync/tryAsync模式