+2

Tôi viết cái package @ltv/env như thế nào - phần 2

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

dotenv là 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 .env chứ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 .env có 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 PORTnumber, 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.haslodash.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 dotenvlodash dependency

Quá đơn giản, chỉ cần xóa ra khỏi package.jsonyarn.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 keydefaultValue (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 rtnValuestring | 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 defaultValueundefined thì rtnValue sẽ là string | undefined, còn nếu defaultValuestring 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à string hoặc string | 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 Tstring, 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 | string Lúc này R là một generic type, nó có thể là undefined hoặc string.
  • defaultValue?: R Lúc này defaultValue có thể là undefined hoặc string.
  • StringOrUndefined<R> Như đã mô tả ở trên, StringOrUndefined sẽ trả về string nếu Rundefined, còn không thì trả về string | undefined.

Vậy là chúng ta đã có thể mô tả được type của rtnValuestring 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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí