DeepSeek Harness深度拆解-18:DSH配置服务设计详解对以抽象类SettingsProvider为核心的配置系统的设计进行了系统介绍,并在此基础上提供了基于内存字典作为数据源的实现。DSH默认使用的配置系统是通过FileSettingsProvider实现的(对应的包为@deepseek-ai/dsh-settings-file),本篇文章将系统介绍背后的设计和实现原理。
1. 配置和配置解析
typescript
export interface Config {
path?: string
dshHome?: string
watch?: boolean
debounceMs?: number
}
四个配置选项说明如下:
- path :配置文件的路径。如果没有显式指定,默认指向DSH运行时主目录下的
settings.yaml文件; - dshHome :上述的DSH运行时主目录。如果没有指定,则使用环境变量
DSH_HOME的值,默认为~/.dsh或者%userprofile%.dsh; - watch:是否监控配置文件的更新,并将更新后的配置内容应用到程序中。默认开启;
- debounceMs:文件被修改到注册的回调函数被执行之间的一段等待窗口,默认为100毫秒。文件系统事件常会连续触发多次,用这个窗口合并短时间内的多次写入,避免频繁重载。
在初始化的时候,上述的原始配置将会被解析成一个具体可操作的规范,具体体现为通过如下这个ResolvedSpec接口体现的结构。其中配置的path和dshHome被转换成表示配置文件绝对路径的filename字段,配置文件的扩展名被转换成通过SettingsFormat类型表示的格式类型('yaml' | 'json')。
typescript
interface ResolvedSpec {
filename: string
format: SettingsFormat
watch: boolean
debounceMs: number
}
type SettingsFormat = 'yaml' | 'json'
两者之间的转换在如下这个函数中完成。
typescript
export function resolveSpec(config: Config): ResolvedSpec {
const filename = resolve(config.path ?? join(resolveDshHome(config.dshHome), 'settings.yaml'))
const format = FORMATS[extname(filename)]
if (format === undefined) {
throw new Error(`settings-file: extension "${extname(filename)}" is not supported (use .yaml, .yml, or .json)`)
}
return {
filename,
format,
watch: config.watch ?? true,
debounceMs: config.debounceMs ?? 100,
}
}
通过解析Config生成的ResolvedSpec绑定在FileSettingsProvider的spec字段上,整个解析过程是在构造函数中完成的。FileSettingsProvider重写了用来返回配置文档路径的documentPath方法,返回的正是ResolvedSpec的filename。
typescript
export class FileSettingsProvider extends SettingsProvider {
private readonly spec: ResolvedSpec
constructor(ctx: Context, public config: Config) {
super(ctx)
this.spec = resolveSpec(config)
}
override get documentPath(): string {
return this.spec.filename
}
}
2. 并发操作串行化
FileSettingsProvider用一个文件同时承载所有以命名空间分割的配置,而有两类异步操作会并发地操作这份文件:
- 加载:初始化时加载原始的配置内容,以及在检测到更新之后重新加载新的配置内容;
- 更新 :通过调用
update、mutate和replace实施三种模式的配置更新,并在进行持久化时更新配置文件。
如果让它们并发,会出现两类竞态,导致数据不一致的情况:
- 写入基于过期的文本
- 加载部分更新的内容
为了解决这个问题,FileSettingsProvider采用并发操作串行化 的策略,将两种针对文件的操作统一写入队列 ,然后在以串行的方式按序执行。这个队列体现在operations字段表示的Promise<void>对象上,本质上体现了针对配置文件的有序操作的调用链,enqueue方法将指定的操作纳入这个调用链中。
typescript
export class FileSettingsProvider extends SettingsProvider {
private operations: Promise<void> = Promise.resolve()
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const task = this.operations.then(operation)
this.operations = task.then(() => undefined, () => undefined)
return task
}
}
3. 配置文件创建、加载与监控
FileSettingsProvider重新了prepareDocument方法实现了配置文件的自动创建(如果不存在的化)。可以看出它正是通过调用上述的enqueue方法将文件创建操作添加到基于配置文件的操作队列中。其中text字段表示从配置文件中读取的原始内容。
typescript
export class FileSettingsProvider extends SettingsProvider {
private text: string | undefined
override prepareDocument(): Promise<string> {
return this.enqueue(async () => {
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
await withFileLock(this.spec.filename, async () => {
try {
await writeFile(this.spec.filename, '', { flag: 'wx', mode: 0o600 })
} catch (error) {
if (isEEXIST(error)) return
throw error
}
this.text = ''
if (!this.isClosed()) this.publish({})
})
return this.spec.filename
})
}
}
重写的用来加载配置内容的load方法会直接读取配置文件,在将读取的文本内容赋值给text字段后,进一步解析为返回的Record<string, unknown>对象。由于此方法仅在初始化时被调用一次,不会出现脏读现象,所以这里采用直接读取的形式。
typescript
export class FileSettingsProvider extends SettingsProvider {
protected async load(): Promise<Record<string, unknown>> {
let text: string
try {
text = await readFile(this.spec.filename, 'utf8')
} catch (error) {
if (!isENOENT(error)) throw error
this.text = undefined
return {}
}
const doc = this.parse(text)
this.text = text
return doc
}
private parse(text: string): Record<string, unknown>
}
针对配置文件更新的监控,以及在检测到更新后的重新加载实现在如下这个初始化方法中。从代码可以看出,如果配置的watch字段被设置成true,此方法会调用chokidarWatch方法(chokidar提供的watch方法)创建一个针对配置文件的FSWatcher对象来监控它的更新,并通过注册的回调函数queueRefresh完成针对配置文件的重新加载。为了避免脏读,queueRefresh将刷新操作添加到针对配置文件的操作队列中。
typescript
import { watch as chokidarWatch } from 'chokidar'
export class FileSettingsProvider extends SettingsProvider {
private closed = false
override async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
yield* super[Service.init]()
const watcher = this.spec.watch
? chokidarWatch(await canonicalizeWatchPath(this.spec.filename), {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: this.spec.debounceMs,
pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
},
})
: undefined
if (watcher !== undefined) {
watcher.on('all', () => {
if (this.closed) return
this.queueRefresh()
})
watcher.on('ready', () => {
if (this.closed) return
this.queueRefresh()
})
}
yield async () => {
this.closed = true
await watcher?.close()
await this.operations
}
}
private queueRefresh(): void {
void this.enqueue(() => this.refresh()).catch((error: unknown) => {
this.ctx.logger.error('settings-file: reload commit failed at %s', this.spec.filename)
this.ctx.logger.error(error)
})
}
}
export declare function watch(paths: string | string[], options?: ChokidarOptions): FSWatcher;
queueRefresh内部调用的refresh方法定义如下,它最终会调用reconcileFromDisk方法读取配置文件的内容来更新text字段,原始的文本内容被解析成Record<string, unknown>对象后作为参数调用publish方法,后者会使新的配置生效并对外发送配置被更新的通知。
typescript
export class FileSettingsProvider extends SettingsProvider {
private async refresh(): Promise<void> {
if (this.closed) return
await this.reconcileFromDisk()
}
private async reconcileFromDisk(): Promise<void> {
let text: string | undefined
text = await readFile(this.spec.filename, 'utf8')
if (text === this.text || this.isClosed()) return
if (text === undefined) {
this.text = undefined
this.publish({})
return
}
const doc = this.parse(text)
this.text = text
this.publish(doc)
}
}
4. 持久化
FileSettingsProvider让重写的writable属性返回true以支持针对配置的写入。在实现的persist中,它将针对persistSection方法的调用添加到针对配置文件的操作队列中。persistSection方法会根据配置的格式分别调用renderYaml或者renderJson方法将配置对象转换成yaml或者json文本,并写入配置文件。
typescript
export class FileSettingsProvider extends SettingsProvider {
get writable(): boolean {
return true
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
return this.enqueue(() => this.persistSection(ns, section))
}
private async persistSection(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
await withFileLock(this.spec.filename, async () => {
await this.reconcileFromDisk()
const output = this.spec.format === 'yaml'
? this.renderYaml(ns, section)
: this.renderJson(ns, section)
await writeFileAtomic(this.spec.filename, output, { mode: 0o600, dirMode: 0o700 })
this.text = output
})
}
private renderYaml(ns: SettingsNamespace, section: Record<string, unknown>): string
private renderJson(ns: SettingsNamespace, section: Record<string, unknown>): string
}