Tôi viết cái package @ltv/env như thế nào - phần 2
Bài đăng này đã không được cập nhật trong 2 năm
Tổng quan
Sau bài viết trước, tôi đã giới thiệu về cách tôi viết package @ltv/env và cách tôi sử dụng nó trong dự án của mình. Trong bài viết này, tôi sẽ giới thiệu về cách tôi optimize package này để nó có thể sử dụng được trong nhiều dự án hơn và phù hợp với TypeScript hơn.
Bài viết gốc: https://lucduong.me/p/toi-viet-cai-package-ltvenv-nhu-the-nao-p2
Các vấn đề cần giải quyết
1. Không phải lúc nào cũng sử dụng dotenv
dotenvlà một công cụ giúp ta quản lý các biến môi trường trong ứng dụng bằng cách lưu trữ chúng trong một tệp định dạng plain text, sau đó đọc tệp này và đặt các biến môi trường tương ứng khi chạy ứng dụng.Tuy nhiên, khi sử dụng dotenv ở
production, có một số rủi ro tiềm ẩn như sau:
Bảo mật: Nếu tệp
.envchứa thông tin nhạy cảm như mật khẩu hoặc khóa bí mật, nó có thể bị lộ khi triển khai ứng dụng ởproduction.Hiệu suất: Đọc tệp
.envcó thể làm chậm hiệu suất của ứng dụng, đặc biệt là khi có nhiều biến môi trường.Quản lý biến môi trường: Sử dụng dotenv có thể làm cho việc quản lý các biến môi trường trở nên phức tạp hơn, đặc biệt là khi có nhiều môi trường (ví dụ: staging, production, development).
Trong môi trường production, thay vì sử dụng dotenv, nên sử dụng các biến môi trường được cấu hình trực tiếp trên server. Điều này giúp đảm bảo bảo mật và hiệu suất, và giúp quản lý biến môi trường dễ dàng hơn.
Ở phiên bản trước của package này tôi có sử dụng dotenv để load các biến môi trường. Tuy nhiên, sau khi sử dụng trong một số dự án, tôi đã nhận ra rằng dotenv không phải lúc nào cũng phù hợp với mọi dự án. Đặc biệt là khi dự án của bạn sẽ được deploy lên môi trường production, dotenv sẽ không phù hợp với môi trường này.
Thực ra cách mà dotenv hoạt động là rất đơn giản, nó chỉ là một module đơn giản, nó sẽ đọc tệp .env và đặt các biến môi trường tương ứng. Sau khi load các biến môi trường, nó sẽ thêm process.env vào require.cache để các module sau có thể sử dụng được.
Nhưng khi sử dụng dotenv trong môi trường production, nó sẽ làm chậm hiệu suất của ứng dụng, đặc biệt là khi có nhiều biến môi trường. Điều này là không mong muốn, vì nó sẽ làm chậm hiệu suất của ứng dụng.
Trong môi trường production, nên sử dụng các biến môi trường được cấu hình trực tiếp trên server. Điều này giúp đảm bảo bảo mật và hiệu suất, và giúp quản lý biến môi trường dễ dàng hơn.
2. Nếu env không được set thì type của env sẽ là gì?
Hãy xem ví dụ sau:
import env from '@ltv/env'
const PORT = env.int('PORT')
Trường hợp này, chúng ta đang muốn lấy PORT từ environment, tuy nhiên chúng ta không set default value cho PORT, nên nếu PORT không được set thì env.int('PORT') sẽ trả về undefined. Điều này sẽ gây ra lỗi khi chúng ta sử dụng PORT. VD:
import env from '@ltv/env'
const PORT = env.int('PORT')
app.listen(PORT)
Kiểu dữ liệu của PORT là number, nhưng
Ở đây, nếu PORT không được set thì app.listen(PORT) sẽ gây ra lỗi.
Tuy nhiên, điều chúng ta mong muốn ở đây không phải là sau khi chạy thì mới biết lỗi, mà là ngay khi development, chúng ta có thể biết được lỗi tiềm ẩn ở đây là gì.
Chính vì vậy, chúng ta cần phải mô tả rõ ràng kiểu dữ liệu của env để khi chúng ta sử dụng env mà không set default value thì nó sẽ báo lỗi ngay lập tức.
-> Ở trường hợp trên, kiểu dữ liệu của PORT sẽ là number | undefined. Điều này sẽ giúp chúng ta biết được nếu PORT không được set thì app.listen(PORT) sẽ gây ra lỗi. (Thực ra là PORT khả năng sẽ là undefined).
-> Tôi cần sửa lại: env.int('PORT') sẽ trả về number | undefined thay vì number.
Lúc này, vấn đề lại phát sinh, nếu tôi set default value cho PORT thì kiểu dữ liệu của PORT không thể là number | undefined nữa, vì nó sẽ luôn luôn là number.
Vậy làm sao để tôi có thể mô tả rõ ràng kiểu dữ liệu của env để khi chúng ta sử dụng env mà không set default value thì nó sẽ báo lỗi ngay lập tức, và khi set default value thì nó sẽ luôn luôn là kiểu dữ liệu đó?
3. Sử dụng nhiều dependencies quá
Mục đích của ứng dụng rất đơn giản là để load các biến môi trường, nhưng tôi lại sử dụng nhiều dependencies quá.
Tôi sử dụng lodash để check xem fields có tồn tại trong object hay không, hoặc merge object cũng dùng lodash, nhưng tôi thấy rằng lodash quá nặng, nó có nhiều tính năng không cần thiết cho package này. Vì vậy, tôi đã thay thế lodash.has và lodash.merge bằng cách viết lại bằng tay.
VD: lodash.has:
/**
* Checks if `key` is a direct property of `object`.
*
* @param {Object} object The object to query.
* @param {string} key The key to check.
* @returns {boolean} Returns `true` if `key` exists, else `false`.
* @example
*
* const object = { 'a': { 'b': 2 } }
* const other = create({ 'a': create({ 'b': 2 }) })
*
* has(object, 'a')
* // => true
*
* has(other, 'a')
* // => false
*/
export function has<T>(object: T, key: PropertyKey): boolean {
return object != null && hasOwnProperty.call(object, key)
}
Hoặc lodash.trim:
/**
* Removes leading and trailing whitespace or specified characters from `string`.
*
* @param {string} [string=''] The string to trim.
* @param {string} [chars=whitespace] The characters to trim.
* @returns {string} Returns the trimmed string.
* @example
*
* trim(' abc ')
* // => 'abc'
*
* trim('-_-abc-_-', '_-')
* // => 'abc'
*/
export function trim(str: string, chars?: string) {
if (str && chars === undefined) {
return str.trim()
}
if (!str || !chars) {
return str || ''
}
const strSymbols = stringToArray(str)
const chrSymbols = stringToArray(chars)
let start = 0
let end = strSymbols.length - 1
while (chrSymbols.includes(str[start])) {
start++
}
while (chrSymbols.includes(str[end])) {
end--
}
return strSymbols.slice(start, end + 1).join('')
}
Bắt tay vào refactor code và upgarde lên version mới
1. Remove dotenv và lodash dependency
Quá đơn giản, chỉ cần xóa ra khỏi package.json và yarn.lock là xong. 
Cách khác là chạy lệnh yarn remove dotenv lodash để xóa cả 2 dependencies này.
2. Refactor code
Phần số 1 chỉ là làm màu cho có chứ phần 2 này mới là quan trọng. Phần này sẽ tập trung giải quyết vấn đề làm sao để có thể mô tả rõ ràng kiểu dữ liệu của env để khi chúng ta sử dụng env mà không set default value thì nó sẽ báo lỗi ngay lập tức, và khi set default value thì nó sẽ luôn luôn là kiểu dữ liệu đó.
Trước khi refactor, function như thế nào?
string<string | undefined>(key: string, defaultValue?: string) {
const rtnValue = has(process.env, key) ? process.env[key] : defaultValue
return rtnValue
},
Function khá đơn giản. Nhận vào key và defaultValue (optional), nếu key tồn tại trong process.env thì trả về process.env[key], còn không thì trả về defaultValue.
Và type của rtnValue là string | undefined.
Như mình đã mô tả ở trên, giá trị trả về luôn là string hoặc undefined, mặc dù có set default value hay không. Điều này khá bất tiện khi sử dụng, khi ta luôn phải cast kiểu dữ liệu trước khi sử dụng. VD:
const host = env.int('HOST', 'localhost') as string
Mặc dù biết rất rõ nếu HOST không được set thì nó sẽ trả về localhost, nhưng vẫn phải cast kiểu dữ liệu.
Nếu nhìn kỹ hơn, thì ta thấy giá trị trả về của rtnValue sẽ phụ thuộc vào giá trị của defaultValue. Nếu defaultValue là undefined thì rtnValue sẽ là string | undefined, còn nếu defaultValue là string thì rtnValue sẽ là string.
Khá là hợp lý, có nghĩa là bây giờ chúng ta chỉ cần kiểm tra xem defaultValue có được set hay không, nếu được set thì rtnValue sẽ luôn luôn là string, còn không thì rtnValue sẽ là string | undefined.
Tuy nhiên, làm sao mà kiểm tra điều kiện cho type được? Vì type không phải là một biến, nó chỉ là một mô tả cho biến. Vậy làm sao để mô tả được điều kiện cho type?
- Đầu tiên làm sao để set return type của function là
stringhoặcstring | undefined?
Để làm được điều này, chúng ta sẽ sử dụng conditional type của Typescript.
export type StringOrUndefined<T extends undefined | string> = T extends string ? string : string | undefined
StringOrUndefined là một conditional type. Nó sẽ trả về string nếu T là string, còn không thì trả về undefined.
Vậy làm sao để mô tả được điều kiện cho type của rtnValue?
function string<R extends undefined | string>(key: string, defaultValue?: R) {
const rtnValue = has(process.env, key) ? process.env[key] : defaultValue
return rtnValue as StringOrUndefined<R>
},
R extends undefined | stringLúc nàyRlà một generic type, nó có thể làundefinedhoặcstring.defaultValue?: RLúc nàydefaultValuecó thể làundefinedhoặcstring.StringOrUndefined<R>Như đã mô tả ở trên,StringOrUndefinedsẽ trả vềstringnếuRlàundefined, còn không thì trả vềstring | undefined.
Vậy là chúng ta đã có thể mô tả được type của rtnValue là string hoặc string | undefined tùy thuộc vào defaultValue được set hay không.
Ví dụ:
const host = env.string('HOST', 'localhost') // string
const user = env.string('USER') // string | undefined
Kết thúc
Chúng ta đã hoàn thành việc refactor code và upgrade lên version mới. Bây giờ chúng ta có thể sử dụng env mà không cần cast kiểu dữ liệu nữa.
yarn add @ltv/env
Sử dụng:
import env from '@ltv/env'
const host = env.string('HOST', 'localhost')
const port = env.int('PORT', 3000)
const isProduction = env.bool('NODE_ENV', false)
console.log(host, port, isProduction)
Bài viết hôm nay khá lủng củng, mình sẽ cố gắng viết những bài viết có chất lượng hơn. Cảm ơn các bạn đã đọc.
All rights reserved