Skip to Content

Свій OWL-сервіс в Odoo 19

Сервіс в 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.

Свій OWL-сервіс в Odoo 19
KitWorks, Volodymyr Karabanov 18 липня 2026 р.
Поділитися цією публікацією
Теги
Архів