みなさん、おひさしぶりです! きみのぶです。いかがお過ごしですか?
おひさしぶりというのも強烈ですね。 1年ぶり の記事です。おそろし。
とまぁ、ものすごく久々のブログ記事ですが、今回は、ようやく……ようやくLiveSyncの大改造ができたので、これを是非書き認めたい! と思って筆を取ってます。
この記事は、大改造、CLI、WebAppと三部作ぐらい続く予定です。
この、大改造! まずはここから話しましょう。
ここしばらく、Self-hosted LiveSyncの大きなリライトをしていたのです。大規模すぎて私もリグレッション起こしたりしてたので、テストを書きたかったというのもありますね。
このフェーズの大きな変更点は4つです。
元のモジュール構造から、明確な依存関係の要求に
元の姿
Self-hosted LiveSyncは元からモジュラー構造になっていて、要らない機能を外したり、特定の機能だけをリライトすることができました。AbstractModuleという基底クラスがあって、coreという大穴が開いてます。
export abstract class AbstractModule<
T extends LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>
> {
get services() {
if (!this.core._services) throw new Error("Services are not ready yet.");
return this.core._services;
}
onBindFunction(core: T, services: typeof core.services) {
// 各モジュールがここでサービスにハンドラを差し込む
}
constructor(public core: T) {}
}ただ、この構造、実はこのモジュールが、「すべての機能が(どこかで)実装されていること」を前提にしていて、実のところモジュール登録順序すら制限があるという、悲しい状態でした。これじゃぁ僕にしか修正できないというのも全然おかしな話ではない。
と言うことで、これをService、ServiceModuleというものに分割していきました。
段階的なサービスへの移行と、Service呼び出しの実装
とはいえ、いきなり全部Serviveとやらを書いて移行できるなら、僕は大規模プログラムの神になれてしまうわけで、実際にはそんな訳無いんです。リリースも滞りますしね。
ということで、アプローチとして、まずは
- Serviceにスタブとしてモジュールの関数を登録する口をつける
- モジュールのまま、呼び出しをService経由にする
この方向で進めました。
まぁ、これは割合うまく行きましたね。
Serviceのメソッドはその後結構入れ替えるんですが、最終的にはこんなシグニチャになりました。あと、型安全にするために、イベントのコネクタも作り直しつつ、handlersというシグニチャの一致を強制されるユーティリティーを作ってます。こいつも助かる。
// 代表例: Service 側で「複数ハンドラを受けるイベント」を型安全に公開
readonly onBeforeRealiseSetting = handlers<ISettingService>().bailFirstFailure("onBeforeRealiseSetting");
readonly suspendAllSync = handlers<ISettingService>().all("suspendAllSync");
readonly onSettingChanged = handlers<ISettingService>().dispatchParallel("onSettingChanged");その次は、Serviceに機能を実装していきます。Serviceは、明示的に依存するサービスを受け取ります。
ここで、テストを回す余地として、Obsidianだから、そうでないから、という切り分けを入れました[1]。
そうこうしていると、この時点ですでにServiceBはServiceAを要求するが、ServiceAはServiceCを要求しServiceCはServiceBを要求している、みたいなのが現れまして……。この検出がうまく行ったのも凄く嬉しかったですね。おかげでAPIセットを階層的に分割できました。
プラットフォーム関係なく実装できる機能が割と多かったのも知見ですね。そういうのが、ほんの1行ほどのObsidian依存のコードなどでダメになっていました。世知辛い……。
あと、どうしても複数のサービスを使う機能、と言うのも発見してしまいました。これはもう仕方ないので、ServiceModuleという、サービスを前提にしたモジュールとして作り直しています。このServiceModuleもこんなシグニチャを持ってて、必要なサービスを明示的に要求する形になっています。
export type NecessaryServices<T extends keyof ServiceHub, U extends keyof ServiceModules> = {
services: Pick<ServiceHub, T>;
serviceModules: Pick<ServiceModules, U>;
};
export function useRedFlagFeatures(
host: NecessaryServices<
"API" | "appLifecycle" | "UI" | "setting" | "tweakValue" | "fileProcessing" | "vault",
"storageAccess" | "rebuilder"
>
) {
// 必要な依存がシグニチャで固定される
}だいたいここまででv0.25.44 ぐらいですね。
余談 ベータリリース
さすがに蛮族リリースするわけに行かないので、この段階で、ベータリリースをしました。絶対やらんと言ってたことなので違和感はありましたが、こんなルールでやってました。今後も、たまにやると思います。
mainには直接入れず、betaブランチで実装0.25.x-patched-yを切って段階リリース
ファイル関連を整理しよう
ファイル関係のモジュールも昔は本当に中途半端で。
過去、これもモジュラーにしようと抽象的なファイルアクセスクラスなどを作ったのですが、結局、ファイルアクセスモジュールという大きな塊でしか存在していなかったんですよね。
ここで、ひたすら悩みまして。
何度かいろんな実装を書いたのですが、一番いい感じになったのは、Copilotと相談した「ロジックとかラッパーはこれで良くて、ファイルアクセスをアダプタにしようぜ」というアプローチでした。
実はあまり気は進まなかったのですが、その通りやってみて驚きで、まぁ素晴らしく整理できました。それも、複数のinterfaceに分割して役割ごとに実装できるようになったので、かなりスッキリしてきましたね。
リスコフの言った「インターフェースは契約である」というのを実感した感じです。
こんな構造になりました。
export interface IFileSystemAdapter<TAbstract = any, TFile = any, TFolder = any, TStat extends UXStat = UXStat> {
readonly path: IPathAdapter<TAbstract>;
readonly typeGuard: ITypeGuardAdapter<TFile, TFolder>;
readonly conversion: IConversionAdapter<TFile, TFolder>;
readonly storage: IStorageAdapter<TStat>;
readonly vault: IVaultAdapter<TFile>;
}
export class ObsidianFileSystemAdapter
implements IFileSystemAdapter<TAbstractFile, TFile, TFolder, Stat> {
readonly path = new ObsidianPathAdapter();
readonly typeGuard = new ObsidianTypeGuardAdapter();
readonly conversion = new ObsidianConversionAdapter();
readonly storage = new ObsidianStorageAdapter(app);
readonly vault = new ObsidianVaultAdapter(app);
}モジュールから、featureへのリライト
これらのやり方で、Service的に実装されるべきメソッドはどんどんモジュールから無くなっていきました。入れ替え可能? 入れ替え可能なんですよ、全部書いていれば。 というのが、本当に実現可能な形で解決した感じですね。
で、Moduleに残ってる残渣のような機能、次はこれを整理しなきゃ……と。
これには、いくつかの種類がありまして。
例えば、オフライン時のストレージ差分の検出ロジックのようなライフサイクルに対して一回だけ呼ばれるモジュールや、ファイルアクセス時、ファイルが対象ファイルかどうかを検証するためのハンドラに機能を注入するようなモジュール、ネットワーク接続時に自動的にリモートの状態をチェックして、容量に関する警告を表示するモジュールなどがあります。
もちろん、それぞれ依存関係も明示的では無いので、それも含めて、必要なものから分割していきました。まだ実害がなさそうなのはいっぱい残ってますけど、そのうち分割します。
featureは、ミドルウエアスタイルにしてます。
必要なサービス・サービスモジュールを受け取り、必要なイベントにフックして、必要なロジックを実装する、みたいな感じですね。
こういうアーキです。
export function useSetupProtocolFeature(
host: NecessaryServices<"API" | "UI" | "setting" | "appLifecycle", never>,
setupManager: SetupManager
) {
host.services.appLifecycle.onLoaded.addHandler(async () => {
host.services.API.registerProtocolHandler("setuplivesync", async (conf) => {
await setupManager.onUseSetupURI(UserMode.Unknown, conf.settings ?? "");
});
return true;
});
}これもアプローチとして大当たりで、共通ライブラリに移せるObsidian依存がないもの、Obsidian側に置くべきもの、と的確に分割できて、さらに、依存が明示的なのでMock, Spyでテストできるようになったんですよね。London School 舐めてたな、というのが、今回得られた本当に大きな知見です。
さらに責任範囲をものすごく明確になった上、分割も簡単になりました。例えば、ファイルが対象かどうかの判定は色々あるんですが、これ全部今までは一つのModuleに入ってました。今回は、feature内でも関数として分割できるので、それぞれテストも簡単になってます。
export function useTargetFilters(
host: NecessaryServices<
"API" | "vault" | "fileProcessing" | "setting" | "appLifecycle" | "database" | "databaseEvents",
"storageAccess"
>
) {
const logger = createInstanceLogFunction("SFTargetFilter", host.services.API);
const checkByDuplication = isAcceptedInFilenameDuplicationFactory(
{ services: { vault: host.services.vault, fileProcessing: host.services.fileProcessing }, serviceModules: { storageAccess: host.serviceModules.storageAccess } },
logger
);
host.services.vault.isTargetFile.addHandler(checkByDuplication, 10);
// 他のフィルタも同様に追加!
}カリー化というのも、まぁ便利やん……と。今回はCS/SE的知見をだいぶ再認識してます。
Self-hosted LiveSyncのCoreと、Obsidianの分割
で、ここまでやると、Self-hosted LiveSyncはかなり整理されまして。
つまり、Obsidian依存でない部分、Obsidian依存な部分が明確に別れたわけです。
なので、思い切って、LiveSyncBaseCoreというのを作り、これに型を持たせたLiveSyncCoreと言うのを、Obsidian プラグインに持たせ、そこで、Obsidianだけで使うモジュールをintroduceするようにしました[2]。
最終的に、プラグインはこういう姿になってます。
export class ObsidianLiveSyncPlugin extends Plugin {
core: LiveSyncCore;
/**
* Initialise service modules.
*/
private initialiseServiceModules(
core: LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>,
services: InjectableServiceHub<ObsidianServiceContext>
): ServiceModules {
const storageAccessManager = new StorageAccessManager();
const vaultAccess = new FileAccessObsidian(this.app, {
storageAccessManager: storageAccessManager,
vaultService: services.vault,
settingService: services.setting,
APIService: services.API,
pathService: services.path,
});
const storageEventManager = new StorageEventManagerObsidian(this, core, {
//
});
// :
// :
return {
rebuilder,
fileHandler,
databaseFileAccess,
storageAccess,
};
}
constructor(app: App, manifest: PluginManifest) {
super(app, manifest);
const serviceHub = new ObsidianServiceHub(this);
this.core = new LiveSyncBaseCore(
serviceHub,
(core, serviceHub) => {
return this.initialiseServiceModules(core, serviceHub);
},
(core) => {
const extraModules = [
new ModuleObsidianEvents(this, core),
new ModuleObsidianSettingDialogue(this, core),
// :
new SetupManager(core),
new ModuleMigration(core),
];
return extraModules;
},
(core) => {
const addOns = [
new ConfigSync(this, core),
new HiddenFileSync(this, core),
new LocalDatabaseMaintenance(this, core),
];
return addOns;
},
(core) => {
const featuresInitialiser = enableI18nFeature;
const curriedFeature = () => featuresInitialiser(core);
core.services.appLifecycle.onLayoutReady.addHandler(curriedFeature);
useOfflineScanner(core);
useRedFlagFeatures(core);
const setupManager = core.getModule(SetupManager);
useSetupProtocolFeature(core, setupManager);
useSetupManagerHandlersFeature(core, setupManager);
// :
}
);
}
private async _startUp() {
if (!(await this.core.services.control.onLoad())) return;
const onReady = this.core.services.control.onReady.bind(this.core.services.control);
this.app.workspace.onLayoutReady(onReady);
}
override onload() {
void this._startUp();
}
override onunload() {
return void this.core.services.control.onUnload();
}
}出来上がり
これらを総合して、できたのがv0.25.53ですね。このおかげで、テストがかなり書きやすくなってます。ユニットテストのカバレッジなんかも数えられちゃう。
そして、これは、次のCLIやwebappにもつながっていきます。