Hello các bạn lại là mình đây 👋👋
Nhân một ngày mà nhìn vào list các bài muốn viết mà mình đã lên ý tưởng từ lâu thấy có rất nhiều thứ muốn chia sẻ, và cũng tranh thủ tuần này rảnh vậy là lại ngứa ngáy muốn viết liền 😁
Hôm nay chúng ta sẽ cùng xem cách để có thể cấu hình dynamic env cho app frontend với Docker và bài toán thực tế nhé
Lên thuyền thôi những người anh em thiện lành ơi 🚀🚀
Vài dòng về Frontend
Tổng quan
Có thể các bạn đã biết (hoặc chưa biết 😂) thì hầu hết các app frontend (FE) hiện tại (2024) đều dùng những library/framework như Vue/React/Angular,... bởi vì chúng giúp việc build frontend rất nhanh, UX tốt, không phải load lại trang, logics JS được bind trực tiếp lên UI, logics thay đổi UI tự update,...
Và còn vô vàn những thứ khác nữa, nói chung mình thấy tech phía FE phát triển với tốc độ khá khủng khiếp và nó có mặt ở mọi nơi, từ web đến native (mobile), desktop.
Và góp phần vào sự phát triển đó thì không thể thiếu được một phần rất quan trọng vào sự phát triển của FE đó là sự ra đời của vô vàn bundler (Webpack, Parcel, Vite, Rollup, Rolldown,...) cái sau ra tốc độ lại bàn thờ hơn cả cái trước 😎😎
Không như backend, FE (modern FE) hầu hết cần phải được bundle thì mới chạy production được, quá trình này về cơ bản là bundler sẽ lấy tất cả source code, assets (image, css, html,...) và tạo ra các file bundle tối ưu (HTML, CSS, JS). Quá trình thì mô tả cơ bản như sau (mình lấy trên trang chủ webpack):
Và có một điều rất quan trọng ta cần chú ý đó là, sau khi bundle thì ta nhận được các file là static, tức là nội dung không (nên) được thay đổi nữa.
Và 1 thứ đó là biến môi trường (ENV) thì sẽ được bundler thay thế ngay tại thời điểm build bằng giá trị thực trước khi sinh ra các file static.
Vấn đề của FE
Bởi vì như vậy nên không như backend, ở các môi trường khác nhau (dev, staging, production...) ta chỉ cần có file .env
và để hết các biến thay đổi theo môit rường ở đó. Thì với frontend sau khi build là mọi thứ đã được "hardcode" static ở đó rồi.
Vậy nhưng hiện nay ở rất nhiều công ty, và cả công ty cũ của mình thì trong deployment workflow của họ follow theo style Build once, run everywhere
: tức là ta chỉ được build app 1 lần và deploy ra các môi trường khác nhau. Điều này có những mặt tốt như sau:
- Đảm bảo chắc chắn rằng tại mọi môi trường ta cùng có 1 bản build y hệt như nhau
- Vì code FE (đôi khi) có thể bị ảnh hưởng bởi môi trường, build cho dev ok xong build staging thì lỗi,...
- Không rebuild khi ra môi trường khác, vì ví dụ build cho staging xong gọi QA vào test ok, xong ra production build lại dùng command khác lại build với 1 source code khác 😂😂
- ...
Cái Build once, run everywhere
kia với backend thì rất dễ, vì xưa nay nó vẫn dễ như vậy rồi 😎, thay biến môi trường đi là xong. Nhưng với frontend thì lại không hề dễ như vậy. Bởi vì frontend nó cần phải build, và nó sẽ được build ra các file static, và code static đó sẽ được gửi tới user khi client truy cập trang web, vậy nên về mặt kĩ thuật với mình thì với frontend không có khái niệm "biến môi trường"
vì mỗi lần code frontend chạy trên trình duyệt của 1 user mà, vậy thì "môi trường" ở đây là môi trường nào 😅
Cần chú ý thật kĩ, khi dev ở local thì chạy trên máy của bạn nên bạn sửa ENV sẽ thấy nó ăn ngay nhưng khi deploy production thì không vậy đâu nhé 😂
Do vậy với frontend thường khi cần dùng tới biến môi trường ta sẽ cần tạo nhiều file env như sau:
Và có một điều lưu ý là các file .env kia được hardcode thẳng vào project và commit lên git (trừ .env.local
). Nếu không thì cũng phải hardcode vào CICD pipeline để "inject" vào quá trình build. Một lần nữa mình phải note lại: đó là biến môi trường với FE nó chỉ có tác dụng tại thời điểm build
Vậy có cách nào để ta có thể làm được điều tương tự như backend hay không? Tức là build 1 lần và tới môi trường nào thì ta dùng 1 file .env
thôi là được không? 🧐🧐
Giải pháp hiện tại
Ta cùng điểm qua một số giải pháp phổ biến hiện tại nhé:
Cách 1: Cách phổ biến nhất chắc vấn là đặt các các file .env
(.env.production, .env.development
) như mình nói bên trên trực tiếp vào source code và ở file package.json
khi cần build cho môi trường nào thì ta dùng command dành cho môi trường đó, nó sẽ select file .env
tương ứng, ví dụ:
"scripts": { "build": "yarn build", "build:staging": "env-cmd -f .env.staging yarn build", "build:production": "env-cmd -f .env.production yarn build"
}
Nhược điểm của cách này thì ta thấy rõ là cần phải rebuild cho từng môi trường.
Hơn nữa cách này ta phải để các file env trực tiếp vào Git, hoặc không thì cũng phải lưu trên CICD để lấy ra lúc build.
Và 1 cái nữa như mình đã nói ở trước, staging QA test oke rồi, nhưng khi ra production phải build lại với command khác, thế nhỡ command đó lại build với source code khác thì sao? 😅
Cách 2: Gọi API khi app vừa load lên để lấy các config cần thiết
Với cách này thì đúng là ta có thể làm dynamic env, vì env giờ đẩy qua cho backend rồi còn đâu 😂. Và với cách này thì ta sẽ cần 1 network call, nếu network chậm thì ảnh hưởng tới toàn bộ app của chúng ta, user là người trực tiếp chịu ảnh hưởng 😜
Và đương nhiên với cách này ta cần phụ thuộc vào backend
Cách này nó cũng gọi là "Remote Config" như mọi người vẫn gọi (ví dụ Firebase Remote Config)
Cách 3: không dùng env, đặt thẳng vào code
let backendURL = '' if (location.hostname.includes('production.com')) { backendURL = 'api-prod.com'
} else if (location.hostname.includes('staging.com')) { backendURL = 'api-staging.com'
} else { backendURL = 'api-dev.com'
}
Ở trên ta dựa vào domain hiện tại mà frontend chạy là gì và từ đó chọn ra backend tương ứng. Với cách này thì ưu điểm là không cần ENV gì nữa.
Nhưng nhược điểm cực lớn đó là nó làm frontend phụ thuộc hoàn toàn vào domain, mà đáng ra, FE "tốt" thì không quan trọng nó được deploy ở domain nào, mọi API call đều chỉ dùng đường dẫn tương đối thôi, ví dụ /api/orders
, phần domain sẽ được tự động tính toán dựa vào domain hiện tại mà FE được deploy
Bởi vì với đoạn code bên trên, giả sử ta đổi domain của FE đi thành example.com
, giờ chả có dev, staging hay production
gì để mà if/else
nữa cả 😂
Hmmmm, vậy giờ có cách nào làm được như backend không ta....🤔🤔🤔🤔
Âu cây ta cùng xem nhé 😉
Dynamic ENV
Ý tưởng
Nếu các bạn để ý thì với frontend phần code bundle sẽ hoàn toàn do bundler chịu trách nhiệm và rất khó để ta có thể thọc vào phần code đó và thêm logics gì đó sau khi code đã được build thành static files
Nhưng mỗi FE project sẽ luôn có 1 phần đó là các files asset public thì sẽ không được bundle, và ta sẽ lưu thông tin về ENV ở những files như vậy, có 2 files ta có thể dùng:
- tạo 1 file JS lưu ENV vào
window
và import nó vào file entryindex.html
- đặt thẳng ENV vào file
index.html
2 cách này cũng xêm xêm nhau ta cùng xem nhé
Clone code
Như thường lệ đầu tiên ta clone source code cho bài này ở đây, nhánh master và folder là fe-dynamic-env
nhé các bạn
Sau khi clone về thì ta chạy thử lên coi sao nha, ta chạy với docker luôn để bạn nào không có NodeJS (npm) cũng xem được luôn:
docker compose up -d --build
Sau đó ta mở trình duyệt ở địa chỉ http://localhost:8080
sẽ thấy như sau nhé:
Ở đây mình đã setup cho các bạn 1 project React + Vite, build cho production bằng Docker và chạy với nginx
Món chính
Giờ ta vô món chính của ngày hôm nay nha 😋😋
Các bạn để ý ta có dòng text "Vite + React". Mỗi khi ta muốn thay đổi nó thì ta sẽ phải sửa code src/App.tsx
sau đó build lại docker compose up -d --build
Giờ ta sẽ cùng nhau đưa dòng text đó vào ENV và làm nó dynamic, mỗi khi sửa chỉ cần restart thay vì build là có thể thấy update ngay nhé 😙😙
Như đã nói ở trên ta sẽ đặt env ở public assets, ở bài này nó sẽ là những files ở trong folder
public
hoặc fileindex.html
(ở root folder project)
Trước hết ta down
app đi đã nhé:
docker compose down
Đầu tiên trong folder public
các bạn tạo cho mình file env.template.js
với nội dung như sau:
window.__HELLO_MESSAGE__ = "${HELLO_MESSAGE}"
Ở trên ta sẽ tạo thêm thuộc tính global __HELLO_MESSAGE__
trong window
, giá trị thì bằng 1 cái string với placeholder là biến môi trường mà tí nữa ta sẽ truyền vào ${HELLO_MESSAGE}
biến global thì kiểu convention là thường ta tránh việc trùng lặp nên người ta hay thêm những dạng tiền/hậu tố như
__
😉
Sau đó ta tạo file env.js
với nội dung như sau:
window.__HELLO_MESSAGE__ = "Default text"
Lí do là tí nữa ta sẽ inject ENV vào file env.template.js
, sau đó xuất content ra file env.js
. Trong trường hợp ta không inject được env vào env.template.js
thì ở env.js
ta có sẵn fallback message để vẫn có thể hiển thị ra cho user
Tiếp theo ta mở index.html
và import file env.js
kia vào:
<!doctype html>
<html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vite + React + TS</title> <!-- =====Ở Đây===== --> <script src="/env.js"></script> </head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body>
</html>
Tiếp theo là ở trong code ta sẽ truy cập tới biến global kia như sau:
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css' declare global { interface Window { __HELLO_MESSAGE__: string; }
} function App() { const [count, setCount] = useState(0) return ( <> <div> <a href="https://vitejs.dev" target="_blank"> <img src={viteLogo} className="logo" alt="Vite logo" /> </a> <a href="https://react.dev" target="_blank"> <img src={reactLogo} className="logo react" alt="React logo" /> </a> </div> <h1>{window.__HELLO_MESSAGE__}</h1> <div className="card"> <button onClick={() => setCount((count) => count + 1)}> count is {count} </button> <p> Edit <code>src/App.tsx</code> and save to test HMR </p> </div> <p className="read-the-docs"> Click on the Vite and React logos to learn more </p> </> )
} export default App
Ở trên ta có 2 thay đổi:
- ở đoạn thẻ
<h1>
ta đã thay đoạn text cũ bằng code lấy message từwindow
- Ở đầu file ta có thêm 1 đoạn code Typescript nhỏ để khai báo thêm 1 thuộc tính vào global window (cái này bạn nào dùng Javascript thì không cần)
Tiếp là bước quan trọng nhất đó là inject env từ Dockfile:
FROM node:20-alpine as build-stage
WORKDIR /app
COPY package.json yarn.lock* ./
RUN yarn --frozen-lockfile
COPY . .
RUN yarn build FROM nginx:1.25-alpine
WORKDIR /usr/share/nginx/html
COPY /app/dist .
EXPOSE 80
# CMD ["nginx", "-g", "daemon off;"] CMD /bin/sh -c "envsubst < env.template.js > env.js && nginx -g 'daemon off;'"
Ở trên ta sửa duy nhất dòng CMD
cuối cùng, ta sẽ dùng envsubst
, đây là 1 CLI tool đọc env từ môi trường và thực hiện thay thế env vào file text. Sau khi inject được env thì ta start nginx như thường.
Có thể các bạn thắc mắc, lúc tạo file env thì nó nằm trong folder public
mà sao ở CMD
thì lại làm ngay ở root, thì đó là do output của bundler, sau khi build thì mọi files public nó đưa hết lên cùng 1 level:
Và giờ, trước khi up
thì ta truyền biến môi trường từ docker-compose.yml
như ta vẫn làm với backend nha 😉:
services: app: build: context: . dockerfile: Dockerfile ports: - 8080:80 environment: - HELLO_MESSAGE=Hello World
Cuối cùng là ta start thôiiiiiiiii 🚀🚀🚀:
docker compose up -d --build
Sau đó ta mở trình duyệt ở http://localhost:8080
vàaaaaaaa pòm pòm chíu chíu 🎆🎆:
Message của ta đã hiện ra ở đó. Giờ mỗi khi muốn thay đổi ta chỉ cần sửa trực tiếp ở docker-compose.yml
và không cần build lại nữa. Y hệt như backend vậy 😎😎😎
Vọc vạch
File env.template.js
Ta thử truy cập http://localhost:8080/env.js
xem có gì nha:
window.__HELLO_MESSAGE__ = "Hello World"
Ở trên ta thấy từ file template nó đã inject được ENV vào và export ra file env.js
này
Tiếp theo ta truy cập http://localhost:8080/env.template.js
sẽ thấy:
window.__HELLO_MESSAGE__ = "${HELLO_MESSAGE}"
Ở đây ta thấy rằng file template này vẫn được truy cập public trong khi nó không có tác dụng gì khi chạy nữa (runtime), do vậy ta có thể xoá nó đi sau khi file env.js
được export thành công.
Ta sửa lại Dockerfile
như sau nhé:
FROM node:20-alpine as build-stage
WORKDIR /app
COPY package.json yarn.lock* ./
RUN yarn --frozen-lockfile
COPY . .
RUN yarn build FROM nginx:1.25-alpine
WORKDIR /usr/share/nginx/html
COPY /app/dist .
EXPOSE 80
# CMD ["nginx", "-g", "daemon off;"] CMD /bin/sh -c "envsubst < env.template.js > env.js && rm env.template.js && nginx -g 'daemon off;'"
Ở trên mình đã thêm đoạn rm env.template.js
để xoá file template đi rồi. Ta build và restart lại app nha:
docker compose down
docker compose up -d --build
Sau đó lại truy cập http://localhost:8080/env.template.js
và ta sẽ thấy:
Và user sẽ không thể truy cập và biết được ta có gì ở file env.template.js
nữa 🤪
Đưa logics vào ENTRYPOINT
Như các bạn thấy hiện tại logics inject ENV ta để ở CMD
, kèm với command start nginx, trông nó khá là xấu 🤣
Ta sẽ chuyển qua dùng ENTRYPOINT
đưa phần logics vào shell script trông cho đẹp nhé.
Các bạn tạo cho mình file entrypoint.sh
ở root folder project với nội dung như sau:
#!/bin/sh # Substitute environment variables in env.js
envsubst < env.template.js > env.js # Remove template file
rm env.template.js nginx -g 'daemon off;'
Sau đó ở Dockerfile
ta sửa lại chút:
FROM node:20-alpine as build-stage
WORKDIR /app
COPY package.json yarn.lock* ./
RUN yarn --frozen-lockfile
COPY . .
RUN yarn build FROM nginx:1.25-alpine
WORKDIR /usr/share/nginx/html
COPY /app/dist .
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
EXPOSE 80 ENTRYPOINT ["./entrypoint.sh"]
Logics thì y hệt các bạn tự thẩm nha 😁
Sau đó ta rebuild lại project và test thử trên trình duyệt để đảm bảo mọi thứ vẫn oke nha:
docker compose down
docker compose up -d --build
Cache env file với HTTP caching
Nếu các bạn để ý thì hiện tại app của chúng ta sẽ luôn load file env.js
đầu tiên sau đó mới load đến những thành phần khác. Ta không dùng async
hay defer
mà ta muốn đảm bảo là env phải ready trước thì mới load app
Điều này dẫn tới một vấn đề có thể xảy ra: như ở ảnh trên ta thấy rằng để load env.js
thì ta cũng tốn 1 network call
Mặc dù env.js
và code bundle frontend đặt chung 1 chỗ, nên nếu FE load nhanh thì env.js
thường load cũng sẽ nhanh, nhưng khi chạy production user nhiều thì sẽ có thể có những trường hợp mà network bất ổn dẫn tới việc env.js
load chậm hơn, dẫn tới toàn bộ app của ta load chậm đi
Để giải quyết vấn đề này ta có nhiều cách, ở bài này ta sẽ dùng HTTP caching nhé. Vì mình thấy đây là 1 kĩ thuật phổ biến, được dùng rộng rãi, nhưng có vẻ hiện tại nó không được phổ biến nhiều tới anh em dev FE nữa. Về cơ bản kĩ thuật này là từ Nginx ta sẽ trả về header "báo hiệu" cho trình duyệt biết rằng "ê cache file này lại nha nó ít thay đổi lắm" 🫠
Đầu tiên các bạn tạo mình file app.nginx.conf
để cấu hình nginx cho app của chúng ta:
server { listen 80; server_name localhost; location / { root /usr/share/nginx/html; index index.html index.htm; } location = /env.js { root /usr/share/nginx/html; expires 5s; # Cache for 1 hour } error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } }
Thực tế file này mình copy từ file mặc định của nginx và chỉ thêm vào đúng 1 đoạn location = /env.js
, ở đây ta nói với trình duyệt là cache file này lại 5 giây (để dễ demo, thực tế ta nên chọn thời gian cao hơn như 1 giờ/1 tuần,...)
Sau đó ta sửa lại docker-compose.yml
chút để mount file này vào container:
services: app: build: context: . dockerfile: Dockerfile ports: - 8080:80 volumes: - ./app.nginx.conf:/etc/nginx/conf.d/default.conf environment: - HELLO_MESSAGE=Hello World
Và giờ ta restart lại lại là được rồi (không cần rebuild vì ta không sửa gì liên quan tới Dockerfile):
docker compose down
docker compose up -d --build
Sau đó ta quay lại trình duyệt và F5 nhiều lần sẽ thấy như sau:
Những gì xảy ra bên trên như sau:
- Lần đầu tiên: load
env.js
qua network - Trong vòng 5 giây tiếp theo nếu ta liên tục F5 sẽ thấy trình duyệt dùng luôn từ
memory cache
- Hết thời gian 5 giây thì lại tiếp tục load
env.js
qua network
Nếu ta inspect request header lên sẽ thấy là nginx gửi về trình duyệt header max-age
như sau:
Khi trình duyệt thấy có Cache-Control
ở header là sẽ thực hiện caching theo những strategy đã được định sẵn
Đưa thẳng vào index.html
Nếu ta thấy rằng việc dùng file env.js
này kiểu gì cũng tốn ít nhất 1 network call, và ta muốn cải thiện việc này thì ta có thể đưa tất cả vào index.html
cũng được luôn 😎😎
Ta sửa lại index.html
xoá luôn đoạn import script cho env.js
đi nhé:
<!doctype html>
<html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vite + React + TS</title> </head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body>
</html>
Giờ ta có thể xoá env.js
và env.template.js
trong folder public
đi rồi. Sau đó ta update lại entrypoint.sh
nữa nha:
#!/bin/sh cp index.html index.template.html sed -i '1i <script>\n window.__HELLO_MESSAGE__ = "${HELLO_MESSAGE}";\n</script>\n' index.template.html envsubst < index.template.html > index.html rm index.template.html nginx -g 'daemon off;'
Ở trên các bạn thấy rằng ta tạo file index.template.html
từ nội dung hiện tại của index.html
, lí do là vì nội dung của index.html
sau khi build thì bundler sẽ inject một đống thứ khác vào và ta không đoán trước được nó là gì, nom nó như sau:
<!doctype html>
<html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vite + React + TS</title> <script type="module" crossorigin src="/assets/index-fk17Nz9V.js"></script> <link rel="stylesheet" crossorigin href="/assets/index-DiwrgTda.css"> </head> <body> <div id="root"></div> </body>
</html>
Như các bạn thấy content của index.html
là random không đoán trước vậy nên với cách này ta không tạo file template riêng
Quay trở lại với file entrypoint.sh
mà ta vừa update, ở đó ta có:
- copy từ file
index.html
ra fileindex.template.html
- dùng
sed
để insert vào đầu file template phần code JS - logics còn lại thì vẫn vậy
Kết quả mà lát nữa ta sẽ nhận được file index.html
với nội dung xêm xêm như sau:
<script> window.__HELLO_MESSAGE__ = "Hello World";
</script> <!doctype html>
<html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vite + React + TS</title> <script type="module" crossorigin src="/assets/index-fk17Nz9V.js"></script> <link rel="stylesheet" crossorigin href="/assets/index-DiwrgTda.css"> </head> <body> <div id="root"></div> </body>
</html>
Vì giờ ta không dùng env.js
nữa nên ta cũng chả cần custom cấu hình nginx nữa, nên ở file docker-compose.yml
ta có thể bỏ đi đoạn mount volume:
services: app: build: context: . dockerfile: Dockerfile ports: - 8080:80 # volumes: # - ./app.nginx.conf:/etc/nginx/conf.d/default.conf environment: - HELLO_MESSAGE=Hello World
Và giờ ta rebuild project rồi start nha:
docker compose down
docker compose up -d --build
Sau khi chạy lên ta sẽ thấy điều tương tự:
Như các bạn thấy thì với cách này ta bỏ hẳn được file env.js
, tiết kiệm được một network call, nhưng hơi "tricky" chút đoạn tạo file template từ file bundle index.html
Ở đây mình làm demo nên các bạn để ý trong
entrypoint.sh
ta đang hardcode trực tiếp biến môi trường ở đó. Khi làm thật, có nhiều biến môi trường thì ta nên tách ra làm 1 file riêng, đọc content file đó lên rồi dùngsed
insert vàoindex.html
Kết bài
Ngày xưa mình cứ nghĩ làm Frontend là quanh quẩn Vue/React/Angular,... UI button, background,... nhưng thực tế là Frontend nó còn có rất nhiều thứ xung quanh để ta có thể "chơi" 😂 và áp dụng vào các bài toán thực tế. Ví dụ như bài này cũng xuất phát từ yêu cầu công việc của mình cần Build once, run everywhere
cho frontend
Hi vọng đã đem tới cho các bạn một cách mới trong việc xử lý ENV cho frontend.
Chúc các bạn cuối tuần vui vẻ hẹn gặp lại các bạn vào các bài sau 👋