Сервіс в Odoo — це синглтон в env.services. Ядро створює кожен сервіс рівно один раз
при старті веб-клієнта, ще до монтування кореневого компонента, і потім віддає той самий
екземпляр усім, хто попросить його через useService. Видно це прямо в коді
(web/static/src/env.js):
export async function mountComponent(component, target, appConfig = {}) {
let { env } = appConfig;
const isRoot = !env;
if (isRoot) {
env = await makeEnv();
await startServices(env);
}
const app = new App(component, { env /* ... */ });
// ...
}
startServices(env) відпрацьовує повністю до того, як App побачить перший компонент.
Звичайний ES-модуль так не гарантує: кожен import — окремий модульний кеш, без
контрольованого порядку відносно інших модулів і без інжекції залежностей.
Реєстрація
Розглянемо приклад — спрощена версія сервісу з нашого модуля kw_ai_assistant:
import { reactive } from "@odoo/owl";
import { registry } from "@web/core/registry";
export const aiAssistantService = {
dependencies: ["notification"],
start(env, { notification }) {
const state = reactive({ isOpen: false, messages: [] });
function open() {
state.isOpen = true;
}
function close() {
state.isOpen = false;
state.messages = [];
}
function toggle() {
state.isOpen ? close() : open();
}
return { state, open, close, toggle };
},
};
registry.category("services").add("ai_assistant", aiAssistantService);
registry.category("services") — окрема під-реєстрація всередині глобального реєстру
(core/registry.js). Зверніть увагу: add(key, value, { force, sequence }) без force
кине DuplicatedKeyError, якщо ключ уже зайнятий, — не тихо перезапише.
Що опиниться в env.services.ai_assistant, вирішує return. Ядро (_startServices
в env.js) бере кожен запис реєстру, викликає service.start(env, dependencies)
і кладе результат у services[name]. Повертати reactive-стан не обов'язково —
стандартний notification-сервіс повертає звичайний об'єкт без жодної реактивності:
// core/notifications/notification_service.js
start() {
// ...
return { add };
},
І це так само робочий сервіс.
Dependencies
Ключові рядки з _startServices (env.js) — саме тут другий параметр start()
збирається:
const entries = (service.dependencies || []).map((dep) => [dep, services[dep]]);
const dependencies = Object.fromEntries(entries);
const value = service.start(env, dependencies);
Тут і контраст із джерелом теми. Стаття Cybrosys показує сигнатуру start(env)
з одним параметром і коментар «add dependencies if your service needs other services» —
але жоден приклад у статті не показує, як ці залежності реально потрапляють у тіло
функції.
Неправильно (за посібником): dependencies: ["notification"] оголошено, а всередині
start(env) { ... } — без другого параметра notification недосяжний нічим, крім
прямого import, а це вже не той екземпляр, що стартувало ядро.
Правильно (Odoo 19): start(env, { notification }) — деструктуруємо прямо
в сигнатурі, як у прикладі вище.
Порядок старту не залежить від порядку реєстрації: _startServices в циклі шукає
перший сервіс, у якого всі dependencies вже присутні в services, і так, поки черга
не спорожніє. Якщо залежність не зареєстрована взагалі, ядро кидає виняток:
throw new Error(
`Some services could not be started: ${[...toStart.keys()]}. Missing dependencies: ${depNames}`
);
Важливо! Точний текст перевірений тестом web/static/tests/env.test.js:
Some services could not be started: b. Missing dependencies: a. Цикл (a залежить
від b, b — від a) впаде в ту саму пастку мовчки: жоден не набере повний список
залежностей, обидва лишаться в черзі назавжди, і прилетить той самий виняток — тільки
з порожнім переліком Missing dependencies, бо формально нічого не зникло. Ну ок,
цикл на практиці рідкість, частіше це просто забутий рядок у dependencies.
useService
Із компонента дістаємо той самий екземпляр без import (kw_ai_assistant, кнопка
в systray):
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class AssistantSystrayItem extends Component {
static template = "kw_ai_assistant.SystrayItem";
setup() {
this.assistant = useService("ai_assistant");
this.state = useState({ open: this.assistant.state });
}
onClick() {
this.assistant.toggle();
}
}
Явний useState() тут не зайвий рядок. useService (core/utils/hooks.js) сам
обгортає результат у useState(), але тільки якщо сервіс повернув reactive-проксі
напряму — так робить ui-сервіс (return ui, де ui = reactive({...})). Наш
ai_assistant повертає звичайний об'єкт { state, open, close, toggle }, reactive
усередині — лише state. Автообгортання тут не спрацьовує, тому потрібен ручний
useState() саме на assistant.state.
Коли сервіс — зайвий
Сервіс виправданий, коли потрібен спільний екземпляр з контрольованим порядком старту
і, можливо, залежностями від інших сервісів. Просту функцію без стану я б
у registry.category("services") не реєстрував. Odoo 19 сам це демонструє — дані
залогіненого користувача не сервіс, а звичайний модуль:
// core/user.js
export const user = _makeUser(session);
Виконується один раз при імпорті, без registry, без dependencies, без useService.
Скрізь, де потрібен user, його просто імпортують — він не залежить від порядку старту
інших сервісів і не потребує DI, тож module-level singleton дешевший.
Якщо ваш функціонал так само не залежить від інших сервісів і не ділить reactive-стан
між компонентами — не реєструйте його в registry.category("services"). Експортуйте
функцію чи об'єкт з модуля і імпортуйте напряму там, де треба.
Тема за матеріалом https://www.cybrosys.com/blog/how-to-build-custom-services-with-owl-in-odoo-19. Код і поведінку перевірено на Odoo 19.0.