+87

Microfrontend, Module Federation - đưa microservices đến với frontend

Cập nhật gần nhất: 27/02/2024

Hello các bạn lại là mình đây.

Cuộc sống của các bạn dạo này có ổn không, có giống bạn hi vọng.... 🤣🤣cuối tuần rồi các bạn có đi chơi không? không thì ở nhà đọc blog của mình cho mình đỡ ế nhé 🥰

Đây là 1 bài mà mình đã ấp ủ từ lâu rồi mà mãi chưa có dịp để viết, phần vì chưa thấy đủ vững về hiệu quả của nó, phần vì cũng hay quên, mỗi lần viết toàn viết về Docker hoài 😄. ở bài hôm nay chúng ta sẽ cùng nhau tìm hiểu về microfrontend, một khái niệm mình nghĩ sẽ khá là mới với nhiều bạn, những lợi ích của nó, ví dụ, và cách mình đã implement nó cho sản phẩm production nhé.

Note: Ở bài này mình sẽ dùng từ microfrontend rất nhiều, nên để cho tiện ta gọi tắt là MFE nhé

Một chút về công việc mình làm hàng ngày

Không giống như các bài post trên Viblo của mình về Docker, deploy, cloud,...Vị trí của mình trên công ty là Frontend Engineer. Tất tần tật mọi thứ về frontend trên công ty hầu như mình đều đã và đang làm.

Ngày mới đầu join công ty thì mình cũng được phân vào các project cụ thể, làm từ Lit Element (web component), đến React/Vue/Node và 2 năm nay thì chủ yếu là Angular. Có những project mình cũng lead các bạn frontend dev khác, nhưng thứ mình làm tốt nhất và hứng thú nhất vẫn là vọc vạch những thứ mới, hỗ trợ các team chọn framework/architecture và setup/deploy.

Hiện tại thì mình vẫn lead vài bạn frontend và bên cạnh đó dành thời gian để viết + maintain in house framework, viết các thư viện share UI components cho các team, port các project cross framework (kiểu build project từ framework này sang project khác, dạng kiểu React -> Angular, Angular -> Web component,...). Đôi khi cũng tham gia vào 1 số task trong các project cụ thể nhưng chủ yếu là guide cho các bạn đồng nghiệp làm sau đó mình nhảy sang project khác.

Được làm việc ở 1 tổ chức lớn với nhiều người, có nhiều yêu cầu, nhiều vấn đề khá là hay ho phát sinh, và nó cũng giúp mình học được thêm nhiều giải pháp để có thể triển khải lên được phần frontend tốt nhất có thể trong từng hoàn cảnh.

Chém đủ rồi bắt đầu thôi nào 🚀🚀

Frontend có cần kiến trúc các thứ như backend?

Ta cùng nhau tản mạn chút nhé 😁

Như các bạn biết (hoặc chưa biết 😃), thì với backend chúng ta có tới 1 tỉ thứ: nodejs, Go, java... rồi Redis, Kafka, Spring Boot, serverless, microservices,.... Kiểu 1 tỉ lẻ 1 topics để ta thể làm, có thể vọc, có thể triển khai. Vì thường backend là phần core, xử lý logics, nghiệp vụ và lưu trữ data, nên thường nó sẽ cần phải kiểu "rất ghê", rất đỉnh để vừa giúp các team dev tốt, vừa dễ triển khai, performance phải tốt, rồi abcxyz các thứ. Điều đó mình công nhận.

Nhìn lại phía frontend, tất cả chúng ta có từ mấy chục năm nay đó là HTML, CSS, Javascript. Có 3 thứ đó hoài không thôi à 😃. Ừ thì có React, Vue, Angular,... nhưng cũng có gì khác đâu ngoài component, props, HTTP, button, css layout, background color,....Code kiểu gì, deploy kiểu gì chả được.

Ừm....cũng có phần đúng 🤔🤔 làm đi làm lại bao năm nay cũng toàn có vậy 😪😪

Khi scale app...

Vấn đề

Thực tế khi đi sâu hơn vào frontend và thực tế gặp những yêu cầu sâu xa hơn, thì mình dần nhận ra là dù frontend, hay backend, ta cũng sẽ gặp các vấn đề tương tự nhau, thậm chí nếu tối giản hoá câu chữ trong từng vấn đề đi thì có khi vấn đề ta có y hệt cho frontend + backend 😉

Khi ta dựng 1 app frontend, thường là tất cả mọi người cùng contribute (đóng góp) vào source của app đó, có thể là mỗi người một tính năng, hay 1 màn hình, hay nhỏ hơn là mỗi người 1 component.

Giả sử project/app của chúng ta bắt đầu scale lên nhiều người hơn, nhiều màn hình hơn, và có sự tham gia của các team, mỗi team 1 nghiệp vụ khác nhau.

Khi đó ta sẽ có 1 project lớn dần theo thời gian, số lượng folder thì bắt đầu nhiều vô kể, vì mỗi team code theo 1 style riêng, mỗi ông trong team lại có cách code khác nhau nữa chứ. Cùng với đó là số lượng package(dependency) cực kì nhiều, npm install thì dài 1 thế kỉ, mỗi khi deploy thì. phải build toàn bộ project sẽ cực kì lâu, tốn rất nhiều resource (CPU/RAM) vì bundler phải bundle tất cả các dependencies lại với nhau để được static files. Team này deploy thì có khi phải chờ team kia xong. Ta sẽ có 1 Git tree cực khủng với ti tỉ commit, mỗi team commit 1 kiểu, commit message thì tuỳ hứng 😄, Git conflict mỗi khi pull cũng tốn vô vàn thời gian để xử lý (resolve). Xong lúc build thì code bị lỗi từ 1 team khác nhau thành ra build cả tiếng đồng hồ xong lỗi.

Rồi khi app chạy ở production thì vì quá to nên mỗi khi user mở app lên thì load cả trăm file JS/css, màn hình trắng xoá 1 lúc lâu mặc dù user chỉ truy cập mỗi trang chủ xem qua tí rồi tắt.

Giải pháp hiện tại

Hiện tại chúng ta đã và đang có những giải pháp riêng cho những vấn đề đó, cho từng hoàn cảnh rồi:

  • Nhiều folder quá ý à? vậy thì mỗi team cho code của họ vào 1 folder tổng, rồi ông thích vẽ gì thì thêm vào folder đó là xong?
  • mỗi team code theo style khác nhau, kiến trúc code khác nhau, commit convention khác nhau à? Vậy thì trước đó phải thống nhất giữa các team, "bắt" anh em theo 1 chuẩn
  • Tốn CPU/RAM khi build à? thế thì mua thêm
  • Git tree cực khủng à? dùng Git submodule được không?
  • App production nặng à? vậy thì lazy load, server side rendering, server component.

Các giải pháp đó có thực sự tốt?

Nếu các bạn để ý, thì thấy một trong những sự khác biệt lớn của frontend so với backend, đó là frontend nó yêu cầu tất cả mọi thứ phải "có sẵn" cùng nhau.

Tức là toàn bộ, tất cả source code, modules, packages đều phải có sẵn lúc dev, lúc build và cả lúc deploy chạy thật.

Kể cả ta có lazy load thì lúc dev/build thì source code của mọi component, modules đều phải có sẵn hết để bundler (như webpack) biết và thực hiện build.

Do đó nếu các bạn để ý thì hầu như với app frontend, ta thường lưu tất cả source code của nó ở cùng 1 chỗ, 1 repo. hoặc dù ta có tách từng màn hình ra xong build thành thư viện đẩy lên npm, thì kiểu gì cũng có 1 app tổng và install tất cả các thư viện đó ở package.json của nó.

Điều chúng ta muốn

Ngẫm sang backend, ở đó ta có microservices, ví dụ Java Spring cho nó cụ thể 😃.

Mỗi backend nằm ở 1 nơi khác nhau, code độc lập, deploy độc lập, thuộc về các team độc lập, coding style, commit style các thứ như nào thì tuỳ từng team, mỗi team cài các package độc lập với nhau. Thời gian build lâu hay nhanh là do backend đó to hay nhỏ, nhiều package. Team nào code có lỗi thì team đó build failed. Code được lưu ở các Git repo khác nhau.

Khi chạy thì chỉ cần API gateway nó biết địa chỉ của từng backend để proxy request là xong.

Nom có vẻ khá là hay đó nhỉ? Liệu với frontend thì chúng ta có thể đưa ra được một mô hình tương tự hay không?

Đó là khi khái niệm Microfrontend ra đời

Microfrontend

Với mình thì Microfrontend không khác gì mấy với microservices mà chúng ta vẫn biết ở backend: nó là một kiến trúc web, cho phép chúng ta chia nhỏ app thành các thành phần nhỏ, riêng biệt, các thành phần này có thể là 1 màn hình, 1 component, hay 1 function, deploy ở những nơi khác nhau một cách độc lập. Cho phép team frontend sự linh hoạt như là microservices đã và đang mang lại cho team backend.

ầy, gù, nghe thì có vẻ hoành tráng, giống backend rồi đó, nhưng phải xem thực tế như thế nào đã 😁😁

Bài toán thực tế

Ở bài này chúng ta sẽ cùng nhau xem về ví dụ thực tế na ná giống vấn đề mình đã gặp phải trên công ty, đang áp dụng microfrontend và chạy ở production rất oke nhé 😎😎

Ta cần build một app (ta gọi là App shell), ở đó ta có nhiều app con, mỗi app con này được gọi là 1 widget, mỗi widget này có thể là 1 component nhỏ, trên màn hình, nhưng cũng có thể là nguyên 1 app đầy đủ với cả routing các thứ của riêng nó.

Mỗi widget này có thể được code với các framework khác nhau, Vue, React, Angular,... Build deploy mỗi widget phải là độc lập , thuộc về các team khác nhau.

Mỗi user khi login, sẽ chỉ được xem 1 hoặc 1 số widgets, do vậy ví dụ nếu 1 user chỉ được xem 1 widget thì không có lí do gì bắt họ load toàn bộ 99 widgets còn lại

Hình dung app của chúng ta như sau: một màn hình có nhiều widget, mỗi widget là 1 component widget.jpg một màn hình là 1 widget, mỗi widget là 1 app hoàn chỉnh đầy đủ với routing

appview.jpg

Ở đây ta thấy rằng nếu ta đi theo cách thường sẽ rất khó để có thể tách được từng thành phần của App Shell ra riêng rẽ độc lập cho từng team, vì như mình đã đề cập ở bên trên, không như backend, frontend nó cần biết tất cả mọi thứ, tất cả mọi dependencies, mọi màn hình mà ta sẽ có để có thể build được widget, kể cả ta có lazy load đi chăng nữa. Còn chưa kể tới việc ở đây mỗi widget lại là 1 framework khác nhau, sẽ cực kì mệt mỏi nếu ta đặt tất cả mọi thứ trong một, bởi vì mỗi framework thì cấu trúc folder nó sẽ khác nhau, thậm chí khác hoàn toàn nhau, package cũng sẽ khác nhau rất nhiều, và nếu ta đặt tất cả chúng vào cùng 1 package.jsonnpm install của App shell thì quá trình dev ở local hay build để deploy sẽ rất nặng và lâu, dẫn tới quá trình phát triển phần mềm bị ảnh hưởng rất nhiều

Ở đây ta có thể nghĩ tới việc tách các widget ra thành các thư viện npm khác nhau, nhưng thực tế như vậy thì ở App Shell cũng sẽ phải cài tất cả các widgetđó, và mỗi khi có widget release bản mới thì ta lại phải update package.json của App shell, deploy lại cả App shell mặc dù chỉ có 1 widget thay đổi.

Webpack Module Federation

Ban đầu mình đã khá là đau đầu đi tìm một giải pháp oke nhất cho bài toán bên trên.

Hầu hết các giải pháp tại thời điểm đó (khoảng 3 năm trước đây), mà họ gọi là "microfrontend" thì mới chỉ làm đến mức từng màn hình, tức là mỗi khi truy cập vào 1 màn hình thì họ sẽ load toàn bộ widget lên, thoả mãn 1 phần bài toán của mình, nhưng kiểu MFE đó cũng rất là "rời rạc", mỗi màn hình gần như độc lập hẳn với nhau, muốn pass data từ màn này sang màn kia thì khó, có cái còn không support.

Điều mình cần là 1 thứ cho phép load bất kì 1 thành phần nào như 1 widget, widget đó có thể chỉ là 1 component nhỏ trên màn hình. Và đặc biệt là cách setup làm sao phải tối thiểu nhất có thể.🤔🤔

Nhiều yêu cầu chuối quá nhỉ 🤣🤣

Thế rồi mình tìm ra Webpack Module Federation, thứ có thể giúp mình giải bài toán bên trên.

Cụ thể Webpack module federation cho phép chúng ta có thể load remote module, những module này có thể là bất kì thứ gì: Component, function, Class,... Việc setup cũng khá đơn giản khi ta chỉ cần extend cấu hình webpack.

Các Remote Module (Widget) có thể được deploy ở bất kì đâu, miễn là App Shell có thể access được. Sau khi User login vào thì tuỳ vào permission mà user có, App Shell sẽ tiến hành gọi tới địa chỉ của các Remote Module và load nó về.

Vì Remote Module có thể là bất kì thứ gì, nên mỗi widget có thể được code theo các cách khác nhau, framework khác nhau, miễn sao nó export thành 1 Javascript module, và ở App Shell ta có chút logics là có thể đọc và render được chúng rồi.

Nghe vẫn ong ong trong đầu, ta cùng đi vào thực hành xem thế nào nhé

Thực hành

Tổng quan

Đầu tiên các bạn clone source của mình ở đây.

Clone xong ta sẽ có như sau:

Screenshot 2023-04-24 at 11.41.03 AM.png

Ở trên ta có app-shell và các widget là các microfrontend, mỗi widget được build với framework/library khác nhau (Vue, react, angular), mỗi widget lại được tạo từ những tool khác nhau dành riêng cho chúng mà ta vẫn biết: @angular/cli, vue-cli. App-shell dùng Angular.

Bởi vì ta dùng Module Federation từ Webpack nên bài này mình lấy ví dụ với các project đều dùng webpack cả cho tiện demo.

Nếu các bạn mở ra xem folder structure của từng project, và đã từng làm qua với các framework đó thì sẽ thấy rằng cấu trúc ko có gì thay đổi nhiều lắm, ở mỗi widgetchúng ta có thêm 2 thứ:

  • file loader: file này sẽ export 1 object bao gồm framework hiện tại là gì react/vue/angular, và component chúng ta muốn export, việc này để lát nữa lúc app-shell load từng widget lên thì nó sẽ biết cách render từng widget cho đúng

Screenshot 2023-04-24 at 11.45.37 AM.png

  • tiếp theo đó là thêm chút cấu hình vào file webpack.config.js hoặc vue.config.js thêm vào ModuleFederationPlugin để nói với webpack rằng hãy build các module sau như microfrontend:

Screenshot 2023-04-24 at 11.48.21 AM.png

các bạn sẽ thấy rằng ở các widget và cả app-shell mỗi project ta đều cần thêm ModuleFederationPlugin vào cấu hình của webpack như vậy.

Thực nghiệm

Ta cùng chạy lên xem mặt mũi nó thế nào nhé. 😉

ta mở terminal ở từng folder và chạy npm start. Sau khi các project chạy lên xong hết thì ta mở trình duyệt truy cập ở địa chỉ http://localhost:4200 ta sẽ thấy màn login như sau:

Screenshot 2023-04-24 at 11.51.28 AM.png

Ở đây các bạn login bằng 1 trong các tài khoản sau (username / password):

  • user1 / user1: được truy cập 1 widget
  • user2 / user2: được truy cập 2 widget
  • user3 / user3: được truy cập 3 widget

Ta thử login với user3 nhé để xem toàn bộ widget 😃

Screenshot 2023-04-24 at 11.53.32 AM.png

yayyyy, app chạy rồi, mặc dù UI nom hơi phèn 🤣🤣🤣

Các bạn bấm Logout, sau đó bật Inspect lên và quan sát Network call nhé. (nhớ clear sạch sẽ đi xem cho dễ nhé các bạn). Ta tiếp tục đăng nhập bằng account user3 nhé:

Screenshot 2023-04-24 at 12.01.01 PM.png

Sau khi login các bạn thấy rằng app-shell mới tiến hành load các widget lên. Còn ở thời điểm trước đó thì app-shell chưa load chúng nên app shell gần như không có gì, nên phần bundle của nó sẽ nhẹ, load sẽ nhanh. Và nếu các bạn mở file main.component.ts bên trong app-shell thì sẽ thấy là ta đang load các widget đồng thời với Promise, cái nào load xong trước thì hiển thị lên trước, không phải chờ đợi nhau.

Để load từng widget (microfrontend) thì mình có 1 file util nhỏ nhỏ xinh xinh 5 chục dòng code, file này sẽ tạo thẻ <script> với src link tới từng microfrontend, mỗi microfrontend nó lại có 1 biến JS riêng lưu file loader và việc của ta là lấy ra biến đó để lấy thông tin của file loader

Screenshot 2023-04-24 at 12.07.17 PM.png

Giờ ta lại bấm Logout và login lại với user1, ta sẽ thấy rằng chỉ có 1 widget được app-shell load:

Screenshot 2023-04-24 at 12.10.26 PM.png

Vọc vạch

Widget

Nếu các bạn để ý thì sẽ thấy rằng ở 3 project widget, thì phần cấu hình ModuleFederationPlugincủa chúng đều rất giống nhau

Screenshot 2023-04-24 at 12.25.58 PM.png

Ở đó ta có name của microfrontend, name này cần globally unique, vì nó sẽ được gán cho 1 biến lưu thông tin của loader mà mình đã nói ở trên. Tiếp đó ta có filename được build ra cho microfrontend này, ta đặt tên là remoteEntry.js, tiếp theo là tất cả các module mà ta muốn expose ra ngoài, các module này sẽ được thêm vào file remoteEntry.js. Cuối cùng đó là cấu hình phần shared dependencies, mục đích là vì mỗi app của chúng ta có thể dùng chung những package khác nhau, và không muốn chúng được load nhiều lần vào app-shell.

Các bạn xem thêm ở đây nhé

À một điều nữa là vì các widget của ta là các microfrontend độc lập, bản chất chúng cũng là những app standalone y như kiểu app-shell vậy, nên chúng cũng sẽ tự chạy được như thường, các bạn thử mở trình duyệt ở địa chỉ http://localhost:3000 sẽ thấy là widget của ta vẫn chaỵ bình thường nhé 😃

Screenshot 2023-04-24 at 12.44.12 PM.png

App Shell

Ở đây thì cấu hình ModuleFederationPlugin sẽ hơi khác 1 chút, vì app-shell nó là nơi trung tâm, giống API gateway ở microservices backend vậy nó không cần ai load nó cả, mà nó đi load widget khác, do vậy ở đây ta chỉ định nghĩa các shared dependencies là các package core của các framework mà ta support.

Chú ý rằng hiện tại mỗi MFE chạy ở 1 địa chỉ khác nhau, và App shell sẽ luôn lấy bản mới nhất của từng MFE, nên khi ta đổi MFE thì App shell sẽ tự được "xem" bản mới nhất, không cần deploy lại

Cross framework rendering

Tiếp theo nếu các bạn tò mò bằng cách nào ta có thể render nhiều framework khác nhau mà không cần phải build thành web component (cách mà mọi người thường nghĩ đến đầu tiên khi nói tới cross-framework).

Ở App shell mình có các wrappers với các framework mà ta support, và với mỗi 1 framework thì mình sẽ dùng trực tiếp API của framework đó để render component của nó:

Screenshot 2023-04-24 at 12.36.39 PM.png

Cách này sẽ cho hiệu quả tối ưu hơn nhiều so với việc dùng web component, không cần setup cầu kì, không cần build trước mới dùng được, bundle size cũng sẽ nhỏ hơn. Component được render ra mặc dù ở framework khác nhưng nó cũng sẽ như 1 phần của app-shell, truyền props xuống framework hoặc nhận event emit từ framework lên cũng rất tiện, dễ control

Với anh em nào chưa làm với Angular thì @Input nó chính là props truyền từ cha vào con bên React hay Vue mà ta vẫn dùng 😉

Trường hợp khuyên dùng

Qua ví dụ trên, ta thấy rằng với sự phát triển của MFE, thì ta cũng có thể kiến trúc frontend để đạt được những thứ như ta vẫn làm với microservices, mỗi frontend là một thực thể độc lập, được quản lý bởi team khác nhau, dùng framework khác nhau, microfrontend không giới hạn chúng ta phải kiến trúc project theo 1 cách cụ thể nào đó, mà cho phép ta vẫn dùng các tool mà ta ưa thích (vue cli, create react app,...) để code, và ta chỉ cần thêm 1 chút cấu hình là xong rồi, việc còn lại Webpack lo hết. Việc build + deploy là độc lập cho từng MFE nên tốc độ phát triển phần mềm sẽ nhanh nếu ta chia tách thành nhiều team, nhiều module.

Và bởi vì việc load MFE nào là hoàn toàn nằm trong tầm kiểm soát của chúng ta, nên sau khi login các bạn có thể gọi ABCXYZ check các kiểu, phân quyền các thứ rồi cho load các MFE tương ứng.

Từ đây ta thấy MFE thích hợp với các trường hợp sau:

  • Project có nhiều team, mỗi team làm việc độc lập, build, deploy khác nhau (nhiều là >= 2 😉). Cho phép các team sự linh hoạt trong việc phát triển
  • Project lớn và ta tìm tới MFE để cải thiện trải nghiệm người dùng, user chỉ load phần UI nào mà họ thật sự xem
  • Project với nhiều framework khác nhau
  • viết shared lib, share style (css), không yêu cầu các team npm install mỗi khi ta cập nhật lib
  • Project lớn, build nhiều, thường gặp lỗi khi build vì nặng, tốn resource ...còn ti tỉ thứ hay ho nữa 😄

Câu hỏi liên quan

Ta chỉ dùng được với Webpack?

Bởi vì ở bài này mình demo với Webpack module federation, nhưng điều đó không phải là chỉ mỗi webpack mới làm được microfrontend nhé các bạn, các bundler khác như Vite, Rollup, Esbuild,... cũng đều làm được.

Các bạn xem thêm ở repo example của module federation nhé. Ti tỉ thứ hay ho mà cộng đồng đang làm được demo ở đó

Giới hạn về framework, library SSR?

Với project được build bằng các framework/library khác như Svelte, NextJS, Preact, hay server-side rendering,... Thì liệu có giới hạn gì không?

Theo mô tả của Webpack module federation thì:

This is often known as Micro-Frontends, but is not limited to that.

Giới hạn của nó không chỉ ở mỗi microfrontend, thực tế về mặt kĩ thuật thì bất kì cái gì miễn nó là Module thì đều áp dụng được hết, Module ở đây có thể là Javascript module hay là cả CSS/SCSS/LESS,....

Tất nhiên với mỗi framework/library khác nhau hay dùng các tool khác nhau (Vite, Rollup,...) thì đôi khi ta cần cấu hình khác đi chút.

Các bạn xem thêm ở repo example của module federation nhé

Vậy là mỗi MFE cần deploy ở 1 domain riêng?

Gần đúng 😃

Như các bạn thấy thì mỗi MFE chúng cần được host ở đâu đó, và có thể được truy cập từ App shell. Nhưng bởi vì mỗi MFE được build ra static files (remoteEntry.js, loader,...) nên ta hoàn toàn có thể dùng 1 domain để share cho cả app-shell và toàn bộ các MFE (dùng các đường dẫn có prefix khác nhau chẳng hạn)

Hot Module Replacement (HMR)?

Các bạn có thể thắc mắc, giờ mỗi thành phần (MFE) ở 1 nơi, vậy khi MFE A thay đổi, thì làm cách nào để App shell biết mà reload lại hoặc thực hiện HMR (Fast Refresh) chẳng hạn?

Khi dev mà không auto refresh khi thay đổi, hay không có HMR thì thật là chậm 🥲

Mấy cái đó đều có hết nhé các bạn, xem thêm ở repo example của module federation nhé

Kết bài

Hi vọng qua bài này các bạn đã thấy được mô hình microfrontend thực thụ là như thế nào (nghe nổ ghê 🤣🤣), trước đây chúng ta cũng có 1 vài mô hình người ta gọi là microfrontend, nhưng thực tế mới chỉ là hybrid và vẫn luôn có sự phụ thuộc giữa app-shell với các MFE trong quá trình build/deploy

Chúc các bạn ngày mới vui vẻ và hẹn gặp lại các bạn ở những bài sau


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.