This page looks best with JavaScript enabled

Odoo JavaScript - Phần 3: Tổng quan về kiến trúc MVC trong Odoo

 ·  ☕ 8 phút đọc · 👀... views

MVC ở đây chúng ta có thể hiểu WebClient của odoo chính là view là Python chính là M và C.
Nhưng chúng ta nên nghĩ về WebClient như một application riêng biệt cũng cần có kiến trúc MVC riêng của nó.

Các class base trong MVC

Mình bắt đầu phân tích Kiến trúc MVC trong Odoo WebClient bằng cách đi vào mã nguồn bên trong /odoo/addons/web/static/src/core/mvc.js.

Nhìn hình bên trên chúng ta có thể thấy, file js này định nghĩa 4 thành phần chính tạo nên MVC đó là Model, Render, Controller và Factory.

  • Model kiểm soát trạng thái, nó sẽ gọi máy chủ thông qua RPC để tìm nạp dữ liệu, cập nhật và tạo nó. Model không extend Widget bởi model chỉ chịu trách nhiệm cập nhật, thể hện trạng thái của dữ liệu(😁không biết mình nói vậy có đúng không nữa)
  • Renderer được extend từ Widget có nhiệm vụ hiển thị dữ liệu cho end user thông qua việc thêm mọi thứ vào DOM. Class này k có quyền truy cập trực tiếp đến Model mà phải thông qua Controller và khi có sự kiện như click chuột,… thì Render sẽ dispath chúng đến Controller
  • Controller có thể coi là một người điều phối giữ Model và Controller
  • Factory hay còn gọi là View, nhiệm vụ chính là từ route URL nhận được, sẽ khởi tạo Model, Controller. Sau khi Controller hoạt động thì Factory sẽ không còn hoạt động nữa
    Từ 4 ý chính trên, bạn có thể vào code đọc từng phần để hiểu thêm nhé!

Các lớp trừu tượng của Kiến trúc MVC

Như trong file mvc.js thì trong addons/web/static/src/js/views cũng được chia thành 4 lớp trừu tượng tương ứng với 4 file js

  • AbstractModel (abstractmodel.js)
  • AbstractRenderer (abstractrenderer.js)
  • AbstractController (abstractcontroller.js)
  • AbstractView (abstractview.js)
    Về bản chất 4 lớp trừu tượng này cũng là extend từ các class trong file mvc.js mà mình đã nói ở trên😁. Bây giờ cùng tìm hiểu từng AbtractClass xem chúng được viết thêm những gì nhé!
    Nhìn có vẻ phức tạp nhỉ, ok chúng ta sẽ đi tới từng lớp trừu tượng 1 nhé!
    Nhìn có vẻ phức tạp nhỉ, ok chúng ta sẽ đi tới từng lớp trừu tượng một nhé!

Model

Cũng giống như Model trong mvc.js, ở đây nó cũng có nhiệm vụ là nạp dữ liệu thông qua rpc và xử lý kết quả nhận được

Ví dụ với BasicModel (được sử dụng trong Form/List Views)

BasicModel mở rộng AbstractModel và là một triển khai thực sự của lớp trừu tượng, để làm cho những gì chúng ta đã nói trước đó rõ ràng hơn một chút, chúng ta sẽ xem xét ví dụ sau
Bạn có thể theo đường dẫn addons/web/static/src/js/views/basic/basic_model.js, bạn sẽ thấy class BasicModel được extend từ AbstractModel
ở đây chúng ta có thể thấy hàm _load:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
 _load: function (dataPoint, options) {
        if (options && options.onlyGroups &&
          !(dataPoint.type === 'list' && dataPoint.groupedBy.length)) {
            return Promise.resolve(dataPoint);
        }

        if (dataPoint.type === 'record') {
            return this._fetchRecord(dataPoint, options);
        }
        if (dataPoint.type === 'list' && dataPoint.groupedBy.length) {
            return this._readGroup(dataPoint, options);
        }
        if (dataPoint.type === 'list' && !dataPoint.groupedBy.length) {
            return this._fetchUngroupedList(dataPoint, options);
        }
    },

Tương ứng với hàm _fetchRecord thực hiện gọi RPC và update dữ liệu hiển thị ra màn hình

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
_fetchRecord: function (record, options) {
        var self = this;
        options = options || {};
        var fieldNames = options.fieldNames || record.getFieldNames(options);
        fieldNames = _.uniq(fieldNames.concat(['display_name']));
        return this._rpc({
                model: record.model,
                method: 'read',
                args: [[record.res_id], fieldNames],
                context: _.extend({bin_size: true}, record.getContext()),
            })
            .then(function (result) {
                if (result.length === 0) {
                    return Promise.reject();
                }
                result = result[0];
                record.data = _.extend({}, record.data, result);
            })
            .then(function () {
                self._parseServerData(fieldNames, record, record.data);
            })
            .then(function () {
                return Promise.all([
                    self._fetchX2Manys(record, options),
                    self._fetchReferences(record, options)
                ]).then(function () {
                    return self._postprocess(record, options);
                });
            });
    },

Từ 2 hàm trên chúng ta có thể thấy lớp trừu tượng model hoạt động như thế nào rồi phải không nhỉ?

Renderer


Như đã nói ở trên, Render nhằm mục đích hiển thị giao diện cho người dùng, cũng như tương tác với các sự kiện trên UI để chuyển về cho Controller
Trong odoo 14, có 2 cách để bạn có thể tạo ra Render là extends AbstractRender hoặc tạo OWL Render, chúng tnhéa cùng đi nhanh qua 2 cách này

  • Legacy AbstractRenderer
    Chúng ta sẽ có 1 main function đó là _render mà cụ thể hơn nó gọi là _renderView bên dưới chứa logic của việc tạo giao diện người dùng.
    Như đây là đoạn code của BasicRender:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
_render: function () {
        var oldAllFieldWidgets = this.allFieldWidgets;
        this.allFieldWidgets = {}; // TODO maybe merging allFieldWidgets and allModifiersData into "nodesData" in some way could be great
        this.allModifiersData = [];
        var oldWidgets = this.widgets;
        this.widgets = [];
        return this._renderView().then(function () {
            _.each(oldAllFieldWidgets, function (recordWidgets) {
                _.each(recordWidgets, function (widget) {
                    widget.destroy();
                });
            });
            _.invoke(oldWidgets, 'destroy');
        });
    },

Khi Controller muốn update, Render sẽ thiết lập và trả lại trạng thái của mình với getLocalState và setLocalState, trước khi được gỡ khỏi DOM, Rendercũng phải reset state với hàm resetLocalState

  • OWL Renderer
    OwlRendererWrapper như một người đứng giữa Render và Controller
1
2
3
4
5
6
class RendererWrapper extends ComponentWrapper {
    getLocalState() { }
    setLocalState() { }
    giveFocus() { }
    resetLocalState() { } 
}

Theo như mình biết thì các hàm ở đây không làm gì cả, nếu bạn muốn chúng hoạt động được thì phải override lại nó
Ví dụ như class PivotRenderer override lại resetLocalState để đặt lại trạng thái OWL component. Bạn có thể vào code của core Odoo từ 14 trở lên tìm class PivotRenderer các bạn sẽ thấy như sau:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
_resetState() {
    // This check is used to avoid the destruction of the dropdown.
    // The click on the header bubbles to window in order to hide
    // all the other dropdowns (in this component or other components).
    // So we need isHeaderClicked to cancel this behaviour.
    if (this.isHeaderClicked) {
        this.isHeaderClicked = false;
        return;
    }
    this.state.activeNodeHeader = {
        groupId: false,
        isXAxis: false,
        click: false
    };
}

Controller

Controller thường quản lý giao tiếp giữa Render và Model. Nhưng nó cũng chịu trách nhiệm trả lời các sự kiện từ ControlPanel hoặc SearchPanel.

1
2
3
4
5
6
_startRenderer: function () {
    if (this.renderer instanceof owl.Component) {
        return this.renderer.mount(this.$('.o_content')[0]);
    }
    return this.renderer.appendTo(this.$('.o_content'));
},

Khi phương thức được start, Render sẽ được thêm vào DOM thông qua $el

Bây giờ chúng ta sẽ điểm qua 2 chức năng chính của Controller:

  • Là cầu nối trung gian để giao tiếp giữa Render và Model
    Thực tế Render sẽ kích hoạt một số sự kiện tới Controller và để đáp lại,Render sẽ thực hiện một số hành động
    Với sự trợ giúp của ActionsMixin, Controller có thể đăng ký một số custom-event mà Controller đang lắng nghe và xử lý
1
2
3
4
5
custom_events: _.extend({}, ActionMixin.custom_events, {
    navigation_move: '_onNavigationMove',
    open_record: '_onOpenRecord',
    switch_view: '_onSwitchView',
}),

Controller cũng xử lý một số sự kiện khi chúng ta click vào các nút trên giao diện có sẵn của Odoo

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
 * This is the main entry point for the controller.  Changes from the search
 * view arrive in this method, and internal changes can sometimes also call
 * this method.  It is basically the way everything notifies the controller
 * that something has changed.
 *
 * The update method is responsible for fetching necessary data, then
 * updating the renderer and wait for the rendering to complete.
 *
 * @param {Object} params will be given to the model and to the renderer
 * @param {Object} [options={}]
 * @param {boolean} [options.reload=true] if true, the model will reload data
 * @returns {Promise}
 */
async update(params, options = {}) {
    const shouldReload = 'reload' in options ? options.reload : true;
    if (shouldReload) {
        this.handle = await this.dp.add(this.model.reload(this.handle, params));
    }
    const localState = this.renderer.getLocalState();
    const state = this.model.get(this.handle, { withSampleData: true });
    const promises = [
        this._updateRendererState(state, params).then(() => {
            this.renderer.setLocalState(localState);
        }),
        this._update(this.model.get(this.handle), params)
    ];
    await this.dp.add(Promise.all(promises));
    this.updateButtons();
    this.el.classList.toggle('o_view_sample_data', this.model.isInSampleMode());
},
  • Truy cập vào Control Panel và Search Panel
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
if (this.withControlPanel) {
    this._updateControlPanelProps(this.initialState);
    this._controlPanelWrapper = new ComponentWrapper(this, this.ControlPanel, this.controlPanelProps);
    this._controlPanelWrapper.env.bus.on('focus-view', this, () => this._giveFocus());
    promises.push(this._controlPanelWrapper.mount(this.el, { position: 'first-child' }));
}
if (this.withSearchPanel) {
    this._searchPanelWrapper = new ComponentWrapper(this, this.SearchPanel, this.searchPanelProps);
    const content = this.el.querySelector(':scope .o_content');
    content.classList.add('o_controller_with_searchpanel');
    promises.push(this._searchPanelWrapper.mount(content, { position: 'first-child' }));
}

View


Khởi tạo 1 view thường có 2 tham số:

1
2
3
init: function (viewInfo, params) {
    //...
}

Mục tiêu chính của quá trình khởi tạo là điền các đối tượng cấu hình sẽ được chuyển đến các sub_component để tạo chúng:

1
2
3
4
this.rendererParams = {};
this.controllerParams = {};
this.modelParams = {};
this.loadParams = {};

3 dòng đầu thể hiện rõ ứng với 3 phần là : Renderer, Controller và Model, còn ***this.loadParams = {};***LoadParams sẽ được sử dụng để tải dữ liệu ban đầu với _loadData và nó sẽ chứa thông tin nếu view đang được “mở” với group-bey, context, domain, limit
Mục tiêu đối với mình là hiểu cách thức JS View thực hiện khi chúng ta tạo ra và bắt đầu từ XML mà chúng ta thường code hằng ngày như thế nào?

Chia sẻ
Support the author with

Hùng Phạm
Viết bởi
Hùng Phạm
Web/Mobile Developer