Skip to main content

8 posts tagged with "vn"

View All Tags

· 2 min read
Luc Duong

Có thực sự mọi dự án đều phải có?

Nghe có vẻ là đao thua búa lớn, nhưng thực sự là cần phải có.

Ở một số góc nhìn khác thì tất cả những gì mình nói ở đây đều không cần tới và gần như không dự án nào có cả. Vậy tại sao lại cần? Cần như thế nào?

Thực sự là có. Bất kể ban đang làm dự án một mình, làm việc với team, dự án mã nguồn mở, hay đang làm ở một công ty thì những dự án phần mềm (Mobile App, WebApp, ...) đều có những điều cần phải được quan tâm:

  • Code standard là gì? Những rule nào được sử dụng trong dự án, code thế nào là clean, thế nào là không clean?
  • Development như thế nào? Setup environment ra làm sao?
  • Commit message đã clean chưa?
  • Code có bug không? Có smell nào không? Có bị trùng với code của người khác không?
  • Làm sao để bump một version? Major, minor, hay patch?
  • Có cần review PR không?
  • Làm sao để triển khai dự án trên server và làm sao để rollback khi có lỗi?

Có hàng tá vấn đề khác liên quan đến technical mà chúng ta cần phải quan tâm

...

· 9 min read
Luc Duong

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.

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

info

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. :D

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.

· 10 min read
Luc Duong

JWT là CMND - Chứng Minh Nhân Dân

tip
  • Tổng quan nó là vậy. Đơn giản nó là vậy. Thực tế nó là vậy. Khẳng định thì nó cũng là vậy.
  • Video: https://www.youtube.com/watch?v=Ue3XVEPcsK8 cho bạn nào muốn nghe / xem nhé.

Để mà nói về JWT thì có quá nhiều người nói về nó, nhưng để mà bảo nó là CMND / CCCD / Passport thì chắc là chỉ có tôi quá.

Nếu để ý kỹ thì thế này, khi bạn chưa được cấp CMND / CCCD thì bạn đi ngân hàng, có lẽ người ta sẽ không làm thẻ cho bạn đâu nhẩy.

Rồi, vậy cấp cho cái CMND để làm gì, để định danh bạn là ai khi bạn cần giao dịch gì đó.

Thế JWT là cái gì? Tương tự CMND thôi nhá. Cũng là để làm cái chuyện định danh xem bạn là ai trước khi bạn cần giao dịch gì đó.

Ai là người cấp CMND?

tip

Dĩ nhiên là cơ quan có thẩm quyền rồi.

  • Huyện / Tỉnh có quyền đại diện cho nhà nước / bộ công an cấp CMND cho bạn.
  • CCCD thì có thể là bắt buộc phải thông qua Cục quản lý cư trú.

Ai là người cấp JWT

Một đơn vị bất kỳ nào đó mang tên IdP -> Identity Provider là người sẽ cấp JWT

Đặc điểm chung của CMND và JWT là gì?

Thông tin mô tả

  • ID: Số CMND / CCCD (Đối với JWT thì nó là user id hoặc còn gọi là sub / subject
  • Thông tin cá nhân: Họ tên, địa chỉ, ... (Đối với JWT thì có thể là các thông tin thêm như email, tên, ... Cái mà gọi là payload hoặc là thêm nữa thì gọi là claims
  • Con dấu (Đối với JWT thì có thể coi như là phần chữ ký SIGNATURE cho dễ hiểu).

Đơn vị cấp / ngày hết hạn

  • Đơn vị cấp: Công An TP. HCM hoặc Cục Quản lý cư dân, ... (Đối với JWT thì nó là cái mang tên issuer / iss -> Ý nghĩa tương tự.
  • Ngày hến hạn: 01/01/2025 (Đối vớiJWT thì là expiry nhưng cũng là ngày giờ hết hạn thôi)

Đổi / cấp mới

tip

Có khi nào ngân hàng giúp bạn đổi CMND / CCCD khi hết hạn không nhỉ?

  • Khi CMND / CCCD hết hạn sau khoảng 15 năm thì người dân sẽ mang CMND / CCCD cũ đến nơi có thẩm quyền để đổi / cấp mới. (Đối với JWT thì cũng tương tự, nhưng thường được nhắc đến với cái tên refreshToken)
  • Nếu mà lỡ có mất CMND / CCCD thì cũng phải có cách nào đó để bạn chứng minh được bạn là ai. VD: Hộ khẩu / Sổ đỏ / Bằng lái xe / Giấy khai sinh ... (Nó cũng coi như là refreshToken đó.
  • Hãy suy nghĩ về câu hỏi mà tôi đặt ra ở trên bạn sẽ thấy là refreshToken sẽ không để ở resources server mà để ở phía client đó =))

Làm giả

tip

Thằng nồi nào cũng làm giả được hết.

  • Đối với CMND / CCCD thì dễ làm giả hơn vì nó chỉ đơn giản là con dấu nên ai cũng có thể làm giả con dấu, trừ khi có dấu hiệu nào đó để cơ quan có thẩm quyền xác minh con dấu hoặc dấu hiệu nào đó nói rằng CMND / CCCD giả.
  • Đối với JWT thì chỉ cần secret key, cái mà mang đi để generate ra cái token đó verify là sẽ được.
  • Đến đây, quay lại CMND / CCCD thì cũng có vẻ hợp lý phết nhỉ. =)) Nếu có phôi thật và con dấu thật thì (giống kiểu chép được key thật) thì cái CMND / CCCD cũng là thật CMNR.

Ai là người xác minh tính đúng đắn

tip

Thường thì cái thằng cấp chả bao giờ nó gặp lại bạn để xác minh đâu

  • CMND / CCCD: VD: Cục quản lý cư dân chả bao giờ đi xác minh lại bạn trừ khi cần. Thường là một đơn vị khác như: Ngân hàng, nơi làm việc, ...
  • JWT: Là cái nơi mà bạn định truy xuất dữ liệu (Resource Server hay là API Server đó).

Làm sao để biết nó là đúng khi không phải người cấp ra

tip

Không phải chỉ đọc thông tin là được đâu nhé. Lỡ đằng sau con dấu bố láo mà cũng nói là ok thì bỏ mẹ.

  • CMND / CCCD: Người cấp / nơi cấp / chính phủ / bộ công an sẽ công bố đặc điểm nhận dạng một CMND / CCCD cho toàn thể nhân dân biết để xác minh một CMND / CCCD là thật hay giả.
  • JWT: Cũng vậy luôn. Nơi cấp sẽ cung cấp secret key cho bên resources / api để verify được cái token xem có đúng không
  • Một cái mà nhiều người ngộ nhận: Dùng cái JWT đem decode ra, lấy được thông tin thế có nghĩa là verify rồi -> Cái này không phải nhá. Nó chỉ giống với việc đọc nội dung trên CMND / CCCD thôi, ai cũng có thể làm điều này. Nhưng cần 1 bước nữa là verify hay có thể coi là xác minh lại con dấu xem có phải do nhà nước / cơ quan có thẩm quyền cấp không.

Bảo mật

tip

Về cơ bản là CMND / CCCD chả có bảo mật gì cả =))

  • CMND / CCCD: Không bảo mật hoặc mình không biết là nó bảo mật. Về cơ bản là ai cũng có thể làm giả khi có đầy đủ thiết bị.
  • JWT Token: Có thể làm giả khi nơi cấp JWT Token bị lộ secret key. Việc lộ này thường xảy ra khi nơi cấp gửi key cho nơi chứng thực. VD: Bạn có 2 cái server khác nhau. Một server là để authenticate và issue JWT Token, một server là resources server. Authentication server cần phải gửi key cho resource server thì resource server mới có thể xác minh token là đúng hay sai.
  • Về cơ bản là để sinh ra cái token thì sẽ dùng secret key, nếu mà không mang nó đi cho người khác xác minh thì về cơ bản nó vẫn an toàn, nhưng nếu để cho nơi khác xác minh thì không nên dùng phương pháp mã hóa đối xứng. Tức là issue token bằng 1 key và verify cũng chính bằng key đó. VD: Google, họ có IdP hay là Identity Provider, có rất nhiều ứng dụng nhờ google làm issue access Token key và chỉ verify xem key đó có dúng là do google issue hay không. Trường hợp này thì nên dùng mã hóa bất đối xứng. Issue bằng 1 key và verify bằng một key khác (Private key và public key).

Một vài hiểu lầm khi làm việc với JWT Token

  • JWT Token là bảo mật, quăng cái gì vào đó cũng được: Sai cmnr. Ai cũng có thể đọc nội dung của JWT Token bằng hàm (Decode) giống y chang việc ai cũng có thể đọc thông tin trên CMND / CCCD.
  • accessToken lưu ở client còn refreshToken lưu ở server: Sai luôn cmnr. Hãy nghĩ tới ví dụ: Ngân hàng giống như Resources server, khi CMND / CCCD của bạn hết hạn thì ngân hàng chỉ nói với bạn là: Anh / Chị ơi, CMND / CCCD của anh / chị hết hạn cmnr, anh chị đi đổi đi nhé. =)) Tự mang đi mà đổi, ai rảnh đâu mà đi đổi cho bạn. Thế nên cái refreshToken là dùng để đi đổi khi accessToken hết hạn nhé
  • accessToken quá lâu hết hạn: VD 1 ngày, 1 tháng: Ối zời ơi, nếu mà CMND / CCCD 100 năm mới hết hạn thì sao nhỉ? Thực ra 15 năm hết hạn là cũng có ý nghĩa của nó. VD sau 15 năm đặc điểm nhận dạng của bạn thường sẽ thay đổi chẳng hạn. Đối với JWT Token thì chỉ nên hết hạn sau khoảng 1h (Mặc định của Google Authentication) hoặc ngắn hơn nữa. 1p / 30s =)). Để đảm bảo là khi ai đó có được token của bạn, chưa làm được gì thì đã hết hạn cmnr.
  • Nếu có được refrehToken là có được tất cả, lúc nào lấy accessToken mới cũng được: Không phải đâu. Nếu mà một user bình thường thì việc đem refreshToken mà đi đổi accessToken mói sẽ không có chuyện gì xảy ra. Nhưng mà một ngày đẹp trời ai đó lấy được refreshToken của bạn rồi đem đi exchange accessToken khi bạn đang dùng hệ thống thì sao nhỉ? Có nghĩa là cái refreshToken được gửi tới IdP lớn hơn 1 lần. Lớn hơn 1 lần đấy nhé =)) Hãy để ý. Lớn hơn 1 lần -> Có nghĩa là hoặc bạn hoặc cái thằng ăn cắp token của bạn là người có vấn đề. Nhưng mà ai có vấn đề thì server nó cũng bảo là có vấn đề, thôi thì tao revoke mẹ nó cái token cho nó lành. Mày là user thật thì mày có thể login lại để có session mới. -> IdP cũng nhanh trí gửi mail cho bạn bảo là có ai đó đang dùng tài khoản của bạn, hoặc tài khoản của bạn có dấu hiệu bất thường =))
  • Mỗi khi request thì sẽ renew token bằng refreshToke: Không phải đâu. Chả lẽ mỗi lần đi ngân hàng hoặc đi máy bay thì phải đi đổi CMND / CCCD mới? Điên à? Mình vẫn dùng accessToken khi đang còn hạn. Nhưng mà mình sẽ kiểm tra lại xem token còn hạn hay không trước khi request, nếu hết hạn thì sẽ renew. VD: Mình lưu accessToken vào cookie, thời gian expire = thời gian expire của token. Nếu get accessToken trong cookie ra mà không còn nữa -> Hết hạn -> Call qua IdP để xin accessToken mới sau đó mới request tới resources server.
  • Authentication serverResources server bắt buộc phải là một: Sai cmnr. Giống y chang CMND / CCCD. Luôn luôn nghĩ đến việc 2 cái này khác nhau. Chỉ giống nhau khi cần giống nhau. VD: Bạn đi làm việc ở một công ty, công ty cấp cho bạn một thẻ nhân viên, và thẻ nhân viên này chỉ dùng ở công ty của bạn. Lúc này là 2 cái là một. Tuy nhiên nó vẫn do 2 bộ phận khác nhau, một là bộ phận cấp thẻ, còn khi bạn vào công ty thì bộ phận khác sẽ xác minh. Có thể là một cái máy chấm công chẳng hạn.

Kết luận

Cái gì cũng từ tự nhiên mà ra cả. Đôi khi bảo sao mà gia đình ấy có hoản cảnh giống trong phim thế. Có vẻ như là phim lấy ngoài đời làm cảm hứng để dựng lên phim ấy.

JWT Token có lẽ cũng vậy nhẩy mọi người.

· 9 min read
Luc Duong

Tổng quan

Chả là việc viết code NodeJS nó cũng rối ren và phức tạp quá, với lại nhiều dự án dùng đi dùng lại những đoạn code giống nhau, utils giống nhau, core giống nhau, ... thế nên là phải đóng gói và đẩy nó lên đâu đó rồi mỗi khi cần sẽ lấy về dùng.

  • package: Cái này là tập hợp những đoạn code mà sẽ sử dụng nhiều lần ở nhiều nơi khác nhau trong một hoặc nhiều dự án. VD: lodash
  • Nơi nào đó: Ở đây có nghĩa là npmjs.org hay còn gọi là npm package registry
  • Đóng gói: Nói thì sang, chớ thật ra tôi ở trong thư mục của cái packge cần publish, tôi chạy lệnh npm publish rồi npm nó tự khắc nén lại thành file có đuôi .tar.gz rồi nó đẩy lên trên npm packge registry giúp tôi
  • Lấy về dùng: Có nghĩa là npm i -S package_name hoặc yarn add package_name đó

Bắt đầu như thế nào

Chắc là phải nói về cái luồng thì nó hợp lý hơn nhẩy:

  • Create project (Tạo thư mục tên package): mk -p package_name
  • init nodejs project yarn init
  • Install mấy package cần thiết cho cái đống này
  • Viết code gì đó (thường là các đồng chí hay copy từ cái mình đã viết rồi, quăng qua đây)
  • Viết Unit Test: Bởi vì là mình sẽ dùng đi dùng lại nhiều ở nhiều nơi khác nhau, nên cần phải đảm bảo mọi code mình viết ra là dùng được và không bị thừa thãi. Và quan trọng hơn là chạy được, không bị sai =))
  • Bundle: Là cái mọe gì vậy? Đơn giản là việc mình gộp các code mình viết ra thành 1 hoặc nhiều file rồi mang ra một thư mục khác (VD: dist)
    • Nếu dùng babel thì bắt buộc phải transpile nó ra dạng javascript thuần để có thể sử dụng được ở chỗ khác
    • Nếu dùng TypeScript thì dĩ nhiên là cũng phải transpile nó ra javascript thuần để có thể sử dụng được ở chỗ khác luôn
    • Nếu dùng Javascript mà muốn bundle lại thì cũng chơi luôn.
  • Đóng gói và publish: Gần như chả bao giờ cần phải quan tâm vì nó được support bởi npm tool hết rồi. :D

Bắt tay vào làm

Published Package - Anh em vào cái này để check source demo nhá. Thực tế là đang dùng luôn

Init project

Tối là tôi đang để project ở cái đường dẫn này. Anh em có thể để đâu cũng được luôn.

mkdir -p ~/ws/ltv/demo-env

Rồi, giờ vô init

cd ~/ws/ltv/demo-env
yarn init

Mặt mũi sau đó nó thế này:

❯ yarn init
yarn init v1.22.10
question name (demo-env):
question version (1.0.0): 0.1.0
question description: Demo safty environment parser
question entry point (index.js):
question repository url: https://github.com/ltv/demo-env.git
question author: Luc <luc@ltv.vn>
question license (MIT):
question private:
success Saved package.json
✨ Done in 65.90s.

Đấy. Check trong thư mục thì các đồng chí kiếm được file package.json

Dùng cái gì?

  • Mình muốn strongly type nên mình quyết định dùng TypeScript cho nó dễ viết. :D
  • Sau khi viết bằng TypeScript mình sẽ build ra javascript và đóng gói cái đống javascript mang đi
  • Vậy giờ dùng những package gì để hỗ trợ việc viết code bằng TypeScript nhẩy
Package nameversionreason
typescript4.2.3Khắc khỏi cần giải thích
ts-node9.1.1Bởi vì là mình sẽ dùng cái đống ngày ở server side nên mình dùng ts-node để có những api cần thiết
@types/node14.14.35Cái này là để có type của nodejs nè

Cơ bản vậy là đủ rồi. Chả cần gì nhiều hơn.

  • Nói thì nói vậy. Chớ muốn viết code tốt thì mình cần phải có unit test, nên mình quyết định dùng jest. Với kinh nghiệm của mình thì jest is the best.
Package nameversionreason
ts-jest26.5.4Dùng TypeScript nên cần thêm thằng này để hỗ trợ transpile
jest26.6.3Phải có là đương nhiên
jest-config26.6.3Hỗ trợ cho việc configuration
@types/jest26.0.21Cái này là type của jest đây
  • Đấy, thế là test ok rồi nhá. Giờ tính đến chuyện lint code, để đảm bảo mình code theo đúng cái chuẩn nào đó, chứ code linh tinh riết rồi không biết mình code thế nào thì bỏ mọe. Vậy là mình dùng eslint
Package nameversionreason
eslint7.22.0Để lint code nha
eslint-config-prettier8.1.0Dùng chung với prettier - ông nôi Microsoft recommend đây
eslint-plugin-jest24.3.2Cái này là lint mấy cái jest unit test
@typescript-eslint/eslint-plugin4.18.0eslint cho TypeScript
@typescript-eslint/parser4.18.0eslint parser cho TypeScript
  • Nghe có vẻ là ổn rồi đấy, nào là test code, nào là check code. Nhưng mà méo. Vẫn còn nhé. Ngoài mấy việc trên, mình cần phải commit code với message chuẩn chỉnh. VD. Các đồng chí hay chơi trò: commit code hoặc fix cái gì đó hoặc refactor, ... Nhìn vô méo muốn review code huống chi là merge.

  • Đến khúc này chắc các đồng chí sẽ nói: Thôi thôi, ông im mẹ nó mồm đi, vậy giờ nó thế nào mới đúng?

  • Ờ thì các đồng chí hỏi tôi sẽ trả lời. Nó nhìn na ná thế này đây:

2021-03-28 09:12 +0700 Luc o [main] {origin/main} feat: add dotenv for loading process.env
2021-03-21 20:04 +0700 Luc o <v1.1.0> v1.1.0
2021-03-21 20:03 +0700 Luc o v1.0.0
2021-03-21 20:02 +0700 Luc o <v0.0.0> 0.0.0
2021-03-21 19:46 +0700 Luc o ci: add NPM_TOKEN, NPM_USERNAME, NPM_EMAIL for authenticating npmjs registry
2021-03-21 19:37 +0700 Luc o <v1.0.0> ci: add GITHUB_TOKEN
2021-03-21 18:55 +0700 Luc o ci: fixed: Failed all unit test
2021-03-21 18:48 +0700 Luc o refactor: change npm registry
2021-03-21 18:39 +0700 Luc o ci: seperate test & release steps
2021-03-21 18:26 +0700 Luc o chore: change github https to ssh
2021-03-21 18:24 +0700 Luc o chore: move release config to package.json
2021-03-21 18:19 +0700 Luc o feat: nodeJs Environment Utils
  • Vậy chớ giờ làm sao? Làm cái mọe gì nữa? Install cái đống ni vô:
Package nameversionreason
commitizen4.2.3
cz-conventional-changelog3.3.0
@commitlint/cli12.0.1
@commitlint/config-conventional12.0.1
  • Và khi commit, các đồng chí đừng dùng git commit nữa nha. Dùng git-cz hộ tôi phát.
  • Nếu thêm vào scripts trong file package.json thì có thể dùng thế này:

package.json

"scripts": {
"commit": "git-cz"
}

Execute command

yarn commit

Rồi rồi, nó sẽ trông thế này:

Alt text

Đấy, nhìn thấy sướng hơn chưa. Giờ thì tha hồ mà chọn kiểu commit, rồi điền thông tin vô nhá.

Alt text

  • Rồi ok. Còn gì nữa không? Còn. Các bố có linting rồi, nhưng mà trước khi commit, các bố quên thì sao? Là một đống 💩 trong code chứ mọe gì nữa. Ấy vậy nên chúng nó nghĩ ra cái lint-staged để đảm bảo các bố nhớ việc lint trước khi commit
Package nameversionreason
lint-staged10.5.4Ở trên kìa
  • Mọe. Dài quá bác ơi. Thì dài thật, nhưng mà, đi tiếp tí nữa đi. Nhá. Còn tí xíu nữa thôi. Vì các bố làm các bố còn phải format code cho nó đẹp tí xíu, nên tôi recommend các bố add thêm cái này nha:
Package nameversionreason
prettier2.2.1
  • Xém hết rồi, ráng lên xíu nhá. Gần tới bước cuối. Có vẻ mọi thứ đã gần như là ok. Giờ đến khúc release rồi này. Các đồng chí sẽ phải thắc mắc đặt tên version như thế nào. Ok. Có một cái tên là semantic-release =)). Install tiếp nhá. Ở bước cuối cùng của bài viết này tôi sẽ lôi các bố vô để giải thích.
Package nameversionreason
semantic-release17.4.2
  • Rồi. Đến cuối rồi. Khi mà các bố push code lên repo, các bố nên chạy cái unit test rồi check xem các bố viết được bao nhiêu unit test, cover được bao nhiêu code viết ra, nhìn vào đó, người ta sẽ quyết định có nên dùng package của bố viết không. Nói thì nói là người ta chớ bản thân các bố cũng tin tưởng mà dùng. Vậy nên tôi recommend các bố dùng cái này nha: ĐỂ PUSH VÀ LƯU VÀ SHARE COVERAGE REPORT. Nhá.
Package nameversionreason
coveralls3.1.0

Ban đầu tính là add thêm cái link cho từng package để các đồng chí xem, nhưng mà nghĩ lại. Ăn sẵn nhiều quá nó hư người. Nên các đồng chí chịu khó chép cái tên package rồi mang lên Google search hộ tôi phát nhé. Linh động lên.

Rồi rồi. Phần 1 kết thúc tại đây. Tôi sẽ lại tranh thủ viết tiếp.

Hóng hộ tôi cái nha. Tôi cũng sẽ quay lại việc làm Video trên Youtube. Chứ mệt quá bỏ cả năm rồi. Bài viết ngày tôi viết lúc rảnh, khi ngồi trên xe đi làm. =))

Còn video thì sẽ làm khi sáng sớm thức giấc.

Các đồng chí ủng hộ tôi tiếp nha.

· 5 min read
Luc Duong
info

Lâu rồi không viết bài gì cả nên nay mình viết một bài xàm xàm xíu. Cũng có hữu ích cho một số bạn làm NodeJS.

Tổng quan

process.env là một biến toàn cục trong NodeJS được set tự động khi app nodejs start. Có thể hình dung thế này: Khi ta set biến môi trường cho hệ điều hành export NODE_PORT=3000 thì ta sẽ lấy được thông tin đó ở process.env, nó sẽ là: process.env.NODE_PORT

Biến môi trường này có quan trọng không?

Quan trọng vãi nồi ra. Để mình ví dụ cái nhẩy. Giả sử app NodeJS của mình có kết nối DB và thông tin nó thế này:

database_host=127.0.0.1
database_user=root
database_port=3306
database_pass=SecurePass

Khi dùng ở local, thì muốn define sao cũng được, set cứng cũng được luôn. Cơ mà khi các bố mang deploy lên trên server / production / dev / hay đại loại là máy thằng khác thì nó ra sao? Nếu mà set cứng thì sẽ phải vào sửa thông tin trong code. Mỗi lần như vậy thì sẽ phải sửa. Nó phiền vcl.

Nếu chỉ có 4 cái bên trên thì chắc là cũng chả vấn đề gì nhẩy nhưng mà thường thì nó sẽ nhiều hơn như vậy rất nhiều, có thể đến vài chục cái.

Chính vì thế những thông tin có thể / cần phải thay đổi khi thay đổi môi trường chạy thì nên (phải) để ở biến môi trường.

Dùng nó như thế nào?

Cơ bản là trước khi start cái app NodeJS lên thì phải set ENV cho hệ điều hành. Nhưng mà nó phiền lắm, nếu có khoảng vài chục cái env khác nhau mà các bố mang đi set trước khi start thì đúng là bệnh vãi. Vậy nên không có ai làm như vậy cả. Thường là nó dùng cái nào đó để set tự động. Nhưng trong trường hợp này thì mình không nên set vào biến môi trường của OS vì nếu mình mà dev vài cái app khác nhau thì nó sẽ bị conflict env. Cách tốt nhất là set vào process.env của mỗi app riêng biệt.

Đâu đó nó thế này:

process.env.DATABASE_PORT=3000

Với NodeJS app thì ta có 1 cái package khá nổi tiếng để set env cho biến process.env đó là dotenv. Và việc của mình là mang hết env vào trong một file. VD: .env / local.env chẳng hạn.

VD: local.env

DATABASE_HOST=127.0.0.1
DATABASE_PORT=3306
DATABASE_USER=root
DATABASE_PASS=SecurePass

Rồi trong code mình load thế này (app.js / index.js):

require('dotenv').config({ path: 'local.env' })

Sau đó thì cái biến global process.env sẽ có toàn bộ những env mà mình đã define ở file local.env và có thể dùng bất cứ đâu trong app NodeJS.

VD:

const connection = new Connection({
host: process.env.DATABASE_HOST,
port: process.env.DATABASE_PORT,
user: process.env.DATABASE_USER,
pass: process.env.DATABASE_PASS
});

Dùng sao cho ngon

Câu chuyện ở trên chắc là các đồng chí đã hiểu rồi nhẩy. Vấn đề bây giờ là dùng sao cho ngon.

Quay lại câu chuyện ở trên, nếu các bố có file env thì không sao, nhưng nếu không có thì cũng vỡ mặt. Cái đống process.env sẽ không có gì. Vậy giờ nếu không có env thì sẽ xử lý sao?

Điều đương nhiên là phải check null rồi set default env chớ mọe gì nữa.

const connection = new Connection({
host: process.env.DATABASE_HOST || 'localhost',
port: process.env.DATABASE_PORT || 3306,
user: process.env.DATABASE_USER || 'root',
pass: process.env.DATABASE_PASS || 'noPASSon'
});

Ờ. Có vẻ là giải quyết vấn đề rồi đấy. Nhưng nếu đi sâu hơn 1 tí, thì tất cả các env đều có value type là string. Có nghĩa là cái thằng process.env.DATABASE_PORT sẽ là: '3306' chứ không phải là 3306. Mà cái mình muốn thì nó phải là 3306.

Tương tự, trường hợp oái oăm hơn nó có thể xảy đến thế này:

Các ông có:

DATABASE_SSL=false

Lúc dùng thì là:

const connection = new Connection({
...otherConfigs,
ssl: process.env.DATABASE_SSL || true
});

Các bố để ý nhé. process.env.DATABASE_SSL là string 'false' Mà string khác empty khác undefined và khác null thì có nghĩa là ssl=true.

Chết mọe rồi. Các bố đang muốn set ssl=false mà. Chuyện phức tạp hơn rồi phải không?

Vậy có nghĩa là phát sinh thêm một chuyện nữa. Các bố bắt buộc phải ép kiểu từ string về kiểu chính xác muốn dùng. Chốt lại có 2 chuyện cần làm:

  • Check null
  • Ép kiểu

Vậy túm cái váy lại là dùng thế nào cho gọn và ngon?

Để cho ngắn gọn thì các đồng chí install thằng này:

yarn add @ltv/env # npm i -S @ltv/env

Và dùng thôi:

const env = require('@ltv/env')
// Or can use with import
import env from '@ltv/env'

const dbPort = env.int('DATABASE_PORT', 3000)
# or string
const dbHost = env('DATABASE_HOST', 'localhost')
# or bool
const useSSL = env.bool('DATABASE_SSL', false)

# First arg is VARIABLE NAME
# Second arg is DEFAULT VALUE

Sử dụng ở ví dụ trên thì nhìn nó thế này:

const connection = new Connection({
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 3306),
user: env('DATABASE_USER', 'root'),
pass: env('DATABASE_PASS', 'noPASSon)',
ssl: env.bool('DATABASE_SSL')
});

Trong bài viết tới chúng ta sẽ tìm hiểu xem viết cái lib này ra làm sao sau nhé. Một số bạn quen với NodeJS, mọi người có thể vào: https://github.com/ltv/env

· 8 min read
Luc Duong

Crawl dữ liệu trên trang chuyencuadev

Vô tình đọc được bài viết trên viblo nên biết được trang này. Mình liền nảy sinh ý định xấu xa, lấy toàn bộ dữ liệu mang về nhà cất.

Dữ liệu thì cũng không có gì ngoài danh sách các công ty IT ở VN (Khoảng ~ 3400). :)

Mình cũng chẳng biết lấy về làm gì nữa. Chắc là để build một cái tương tự. =))

Chuẩn bị

Để lấy được thì cũng phải chuẩn bị một vài cái trước khi lấy chứ nhỉ.

1. Yêu cầu

Yêu cầu thì đơn giản rồi. Lấy danh sách 3491 công ty từ trang chuyencuadev

2. Kiểm tra hàng họ

Check một hồi chả biết là họ đang dùng cái quỉ gì để làm được cái web này nữa. =))

Cơ mà thấy một nút Xem thêm to bự chảng gần cuối trang. Mừng thấy mẹ. Nhấn thử xem nó có cái gì.

Buồn thối ruột vì họ dùng server render, không phải XHR request. Vậy là không vui rồi. Vì lúc này mình chẳng chôm chỉa được dữ liệu có cấu trúc dạng JSON hoặc XML gì đó.

Lúc này mình phải nghĩ đến việc parse html rồi. Nhìn quanh quẩn thì thấy trên domain có cái /page/2.

Ồ. Có thể dùng được rồi. Bắt tay vào làm thôi.

3. Lựa chọn công cụ, ngôn ngữ.

python hoặc nodejs

Mình khoái nodejs hơn nên quyết định chọn nodejs cho game này.

Phân tích

Cấu trúc

Chẳng biết cấu trúc của cái nồi này ra sao, nên phải mò một lúc. Mình thấy họ phân trang, mỗi trang có n công ty. Mà cụ thể là:

À, có cái list-companies và trong đó có nhiều items tile. Hehe Bao nhiêu item đây.

-> Có 3491 / 20 ~ 175 pages Chốt hạ có 175 pages cần khai thác.

Tức là mình sẽ có một vòng for chạy từ 0 đến 174 và parse dữ liệu.

Tạm dừng ở đây đã.

Phân tích sâu hơn.

Mình có 175 lần lặp, mỗi lần sẽ parse data để lấy dữ liệu. Vậy thì hàm parse đó sẽ làm gì?

  • Get html từ https://chuyencuadev.com/page/${i}
  • Dùng DOM Parser hoặc cái gì đó giống jQuery để có thể động đến các thẻ html một cách nhanh nhất. Để đó, search sau.
  • Xác định đối tượng list-companies
#list-companies
.tile
.tile-icon img src (logo link)
.tile-content
.tile-title [0] => Company Name & Review Link
a href (review link)
text (company name)
.tile-title [1] => Info (Location, type, size, country, working time)
icon
text (Info - Repeat 5 times)
.tile-title [2] => Reviews (count, star)
a>span text => count
>span
i*5 (i | i.none)
  • Define Company Object (Sau khi đã xác định được các thành phần của một đối tượng)
{
id: ObjectId,
name: String,
reviewLink: String,
logo: String,
location: String,
type: String,
country: String,
workingTime: String,
reviewCount: Number,
star: Number,
}

Chết cha. Toàn bộ chỉ có thế. Code thôi.

Code

Create Project

mkdir -p ~/Project/crawler/crawl-companies

Packages

yarn add mongoose connect-mongo moment lodash request request-promise cheerio

Một vài thư viện mà mình nghĩ là mình sẽ dùng. Cơ mà chắc chỉ dùng mongoose, connect-mongo, request-promise, cheerio thôi. Còn momentlodash thì mình chưa biết sẽ làm gì với nó. Cơ mà trong đầu nghĩ là sẽ dùng. =))

cheerio => jQuery basic for nodejs.

index.js

Thử code cái đã. :D

const mongoose = require('mongoose')
const request = require('request-promise')
const cheerio = require('cheerio')

const URL = 'https://chuyencuadev.com/'
const companyCount = 3491 // Cái này là mình thấy trên trang này nó ghi vậy. =))
const pageSize = 20 // Đã test. :)
const pageCount = parseInt(companyCount / pageSize)

/**
* Get content for each page
*
* @param {*} uri (Ex: ${URL}page/2)
*/
const getPageContent = (uri) => {
const options = {
uri,
headers: {
'User-Agent': 'Request-Promise'
},
transform: (body) => {
return cheerio.load(body)
}
}

return request(options)
}

getPageContent(`${URL}page/2`).then($ => {
console.log(`Tile > `, $('title').text())
})

Well done! Ta có output ưng ý.

console.log(`Tile > `, $('title').text())

hmmm... Cùng thử lấy ra list-companies như mình mô tả ở trên nào.

/**
* Parse html to companies
*
* @param {*} $
*/
const html2Companies = ($) => {
const companies = []
$('#list-companies .tile').each((_, c) => {
console.log($(c).html())
})
return companies
}

-> Tuyệt cú mèo. Mọi thứ đúng như những gì mình làm với jQuery trên client. :) Vậy thì cứ thế mà chiến tiếp thôi.

Mình sẽ tiến hành parse list of companies

/**
* Parse html to companies
*
* @param {*} $
*/
const html2Companies = ($) => {
const companies = []
$('#list-companies .tile').each((_, c) => {
companies.push(html2Company($(c)))
})
return companies
}

Còn đây là parse từng company Object

/**
* Parse html to company Object
*
* @param {*} $
*/
const html2Company = ($) => {
// logo
const logo = $.find('.tile-icon img').attr('data-original') // Lúc đầu mình tưởng là src, nhưng không, họ dùng data-original sau đó dùng jquery để chuyển sang src. Thôi thì cái nào cuũng được =))
const cName = $.find('.tile-content .tile-title:nth-child(1) a')
const name = cName.find('span').text()
const reviewLink = cName.attr('href')
$.find('.tile-content .tile-title:nth-child(2) i').replaceWith('|') // Hơi độc. Cấu trúc của họ là <i> text <i> text ... Mình phải replace thẻ `i` thành separator `|`
const details = $.find('.tile-content .tile-title:nth-child(2)')
.html().split('|').map(d => d.replace(/^\s+/, '')) // Sau đó split về mảng và replace first `\s` character
const reviews = $.find('.tile-content .tile-title:nth-child(3)')
const reviewCount = reviews.find('a>span').text() // reviewCount
const star = reviews.find('>span i:not(.none)').length // Những cái nào không `none` thì nó là số lượng star được voted.

return {
name,
reviewLink,
logo,
location: details[1],
type: details[2],
size: details[3],
country: details[4],
reviewCount,
star,
}
}

Và response theo ý muốn...

[
{
name: 'CodeLink',
reviewLink: '/codelink/reviews',
logo: 'https://cdn.itviec.com/system/production/employers/logos/3074/codelink-logo-170-151.png?1476957849',
location: 'District 3, Ho Chi Minh',
type: 'Product',
size: '11-50',
country: 'Viet Nam',
reviewCount: '10',
star: 3
},
...
]

Cũng gọi là chim ưng với kết quả này. Mình bắt tay làm tới. Quất thẳng 172 cái pages và lưu vào trong mongo-db

Define DB & Schema

Mình hơi màu mè tẹo. Tạo hẳn cái Schema cho nó vui. models/Company.js

const mongoose = require('mongoose')
const Schema = mongoose.Schema

const companySchema = new Schema({
name: String,
reviewLink: String,
logo: String,
location: String,
type: String,
country: String,
workingTime: String,
reviewCount: Number,
star: Number,
}, {
timestamps: true
})

const Company = mongoose.model('Company', companySchema)

module.exports = Company

Và đúng thủ tụ.

const crawl = async() => {
const companies = await getPageContent(`${URL}page/2`).then($ => html2Companies($))
return Company.create(companies)
}

mongoose.Promise = global.Promise
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/crawl', {
useMongoClient: true
}, (error) => {
if (error) {
console.log('%s MongoDB connection error. Please make sure MongoDB is running.', chalk.red('✗'))
process.exit()
}

crawl().then((companies) => {
if (!companies)
return
console.log(`Created ${companies.length} companies`)
return
}).then(() => {
process.exit()
})
})

Kết quả có vẻ không đẹp cho lắm khi một số request bị timeout. :(. Mỗi lần như vậy mình lại phải chạy lại từ đầu thì không ổn. Mình quyết định viết một recursive function. Hi vọng sẽ chạy những cái page nào bị timeout trước đó.

Cái hàm đó nhìn như thế này.

const crawl = async(pages, results) => {
const chunks = await Promise.all(pages.map(uri => crawlPage(uri)))
const availableChunks = _.filter(chunks, c => typeof c === 'object')
const remainPages = _.filter(chunks, c => typeof c === 'string')
if (availableChunks.length > 0) {
results = await Promise.all(availableChunks.map(companies => Company.create(companies)))
.then((data) => data.reduce((page1, page2) => page1.concat(page2)))
}
if (remainPages && remainPages.length > 0) {
console.log(`Remain ${remainPages.length}.`)
results = results.concat(await crawl(remainPages, results))
}
return results
}

Kết quả

Chả được cái tích sự gì khi mà server bên đó hơi cùi tí tẹo. Mới chạy có mấy chục cái request liên tục mà tải không nổi. Bị request timeout, nhảy vô web của họ thì thấy bị hi sinh luôn.

Kiến thức đã dùng

  • nodejs ES6 (Node v8)
  • async/await
  • Promise

Source Code

git@github.com:lucduong/crawl-companies.git

https://github.com/lucduong/crawl-companies

Kết luận.

Thôi dẹp, lấy được đến đâu lấy. =)).

Đùa vậy thôi, mình có sửa lại chỗ này tí Company.create(companies) thành createCompanies(companies) và trong hàm createCompanies mình findOneAndUpdate để sau này stop, start lại nó ko update trùng.

Đúng ra còn nhiều cách tối ưu khác nữa nhưng mình xin phép nhường lại cho bạn đọc. Và hi vọng các bạn sẽ contribute thêm.

Có thể là mình sẽ đánh dấu lại những trang nào đã lấy dữ liệu rồi và loại trừ, lần sau chạy lại sẽ ko lấy nữa. Các bạn làm giúp mình với nhé. :D

· 6 min read
Luc Duong

Từ hàng tỉ phép so sánh đến 10 giây.

Mình mới làm một dự án nho nhỏ về xử lý dữ liệu cho khách hàng X. Dữ liệu không lớn lắm, chỉ vài trăm MB nhưng cũng có khá nhiều điều để nói.

Mình viết bài này để chia sẻ lại với anh em cách mà mình đã làm nhé.

Bài toán

  • Trong DB (MY_DOMAIN) mình có khoảng 500K domains có dạng /^[\w]+(\.com)?\.vn$/
  • Hàng ngày mình phải tải dữ liệu từ trên một số trang web nước ngoài về, Khoảng (~4 triệu domains) được lưu trong các files (~20 files khác nhau). Các domains này có dạng bất kỳ, đúng chuẩn tên miền. :)
  • Nhiệm vụ
    • Tải 20 files về (~350MB mỗi ngày)
    • Duyệt ~4 triệu domains trong 20 files đấy
    • Kiểm tra xem domain thứ i có nằm trong cái MY_DOMAIN không, nếu có thì đưa vào một cái bảng mới DB_RESULT
    • Báo kết quả ? domains, ? inserted, ? updated, ? matched

Tiếp cận bài toán

  • Đại ca khách hàng nói rằng 4 triệu đấy nhân với lại 500 ngàn tức là khoảng 2.000.000.000.000 phép so sánh.
  • OK. Em cứ tiếp nhận vậy đã. 1 giây xử lý được khoảng 1 triệu phép tính. Tức là con số trên bỏ đi 6 số 0, mình còn lại 2.000.000 giây. Chết mợ rồi. Nếu 2 triệu giây có nghĩa là: 2 triệu / 60 = 33 ngàn phút.
  • Có nghĩa là rơi vào khoảng 555 giờ hay chia cho 24 là ra khoảng hơn 23 ngày
  • LOL, 23 ngày nếu chạy trâu bò => Không ổn tí nào.

Giải quyết vấn đề

  • Suy nghĩ thêm thì sẽ thấy bảng chữ cái và số nó bắt đầu từ 0-9 & a-z, cho nên. Mình quyết định làm thế này.
    • Chia cái DB mà có 500 ngàn records của mình ra làm 36 bảng. Mỗi bảng có n dòng. Đẹp đẹp là 500.000/36=13,889 records
    • Mỗi 1 domains trong ~ 4 triệu domains, mình sẽ lấy chữ cái đầu tiên và chỉ search trong cái bảng prefix_${first_char_of_domain}
    • Ồ, tốc độ có vẻ cải thiện. 4,000,000 * 13,889 = ~55,000,000,000 => 2 ngàn tỉ xuống còn 55 tỉ => Còn khoảng 0,5 ngày ~ 12 hours.
    • 23 ngày xuống còn 12 giờ cũng đẹp phết nhưng mà ai chấp nhận cái nồi đấy.
  • Tiếp tục suy nghĩ. Xong cmnr, mỗi lần lại phải search xem trong db có cái domain ấy không thì không ổn. Vậy làm thế nào bây giờ.
  • OK. Mang 36 cái bảng ấy lên 36 cái mảng để search local cho nó nhanh. Lúc này chịu khó tốn ram 1 tí. Nhưng mà chấp nhận được.
  • Suy nghĩ, Duyệt 4,000,000 lần qua 13,889 bực lắm. Có cách nào so sánh nhanh hơn không.
  • Mình nghĩ đến một cách là. Lúc insert domains xuống database MY_DOMAIN ấy. mình quyết định lưu thêm 1 record là domainWord. VD: ltv.vn thì sẽ có 2 fields: domain, domainWord, records tương ứng là: ltv.vn, ltv
  • Lúc lấy lên DB mình sẽ có một cái HashMap như sau:
const domainMap = {
'ltv': ['.vn'],
'lucduong', ['.com.vn', '.vn'],
//... -> Max của cái hash này là 500 ngàn keys
}
  • Giờ duyệt mỗi 4,000,000 domains
    • VD: domains[i]ltv.net
    • Mình chỉ lấy ltv và search trong cái domainMap của mình.
    • insertToResultDB({domain: 'ltv', suffixes: domainMap['ltv']}) Lúc này suffixes là cái [.vn]
  • Độ phức tạp giải thuật của HashMap khi lookup key là ~O(1) cho nên tốc độ lookup key sẽ rất nhanh.
  • Tuy nhiên mỗi lần thấy trong HashMap thì lại insert thì performance sẽ giảm lắm. Vì cứ phải connect, insert, next. Mình không hài lòng nên đã chơi cái trò là
  • addDomainToBulk({domain: 'ltv', suffixes: domainMap['ltv']})
  • Kết thúc vòng lặp 4,000,000, mình quất bulkInsert cái mảng.
  • Final result: ~30 seconds thỉnh thoảng dữ liệu nhiều hơn thì rơi vào khoảng 1 phút
  • Vẫn chưa hài lòng lắm mình quyết định chia cái domainMap thành "36 cái maps"
const domainMaps = {
//...,
'l': {
'ltv': ['.vn'],
'lucduong': ['.com.vn','.vn']
},
'v': {
'viblo': ['.com.vn', '.vn']
},
//...,
}
  • Mục đích là để giảm số lần lookup key của cái map.
  • Thực sự là giảm rất nhiều. (cwl)
  • Sau đó final result của mình chỉ giao động trong khoảng 10 giây ~ 20 giây

Code mẫu

Hàm thêm dữ liệu vào bảng MY_DOMAIN

const importDomainsFromExcel = async (domains) => {
const promises = []
let tablePrefix = 'MY_DOMAIN_'
const tbMap = {}
_.forEach(domains, d => {
// EX: d = ltv.vn
let firstChar = d[0]
let tableNm = `${tablePrefix}${firstChar}`
let domainWord = d.replace(/(\.com)?\.vn/g, '') // => domainWord = 'ltv'
if (!!tbMap[tableNm]) {
tbMap[tableNm].push({
domain: d,
domainWord,
})
} else {
tbMap[tableNm] = [{
domain: d,
domainWord,
}]
}
})

Object.keys(tbMap).forEach(tableNm => {
promises.push(insertDomainsToMyDomain(tableNm, tbMap[tableNm])) // bulkInsert
})

return Promise.all(promises)
}

Hàm thêm nhiều domains vào bảng MY_DOMAIN_X

const insertDomainsToMyDomain = async (tableNm, domains) => {
// get connection assign to db
const collection = db.collection(tableNm)
const bulk = initBulkInsert()
_.forEach(domains, d => {
bulk.insert(d)
})
return bulk.execute()
}

Hàm đọc dữ liệu từ nhiều bảng MY_DOMAIN_ và assign vào HashMap

// get CHARS from constants ['0', '...', '9', 'a', ..., 'z']
const fetchDomainsToHashMap = async () => {
const domainMap = {}
let tablePrefix = 'MY_DOMAIN_'
const promises = []
_.forEach(CHARS, c => {
let tableNm = `${tablePrefix}${c}`
promises.push(fetchDomainsFromCollection(db.collection(tableNm)))
})

const domains = await Promise.all(promises).then(async (results) => {
const resDomains = []
_.forEach(results, _domains => {
resDomains.concat(_domains)
})
return resDomains
})

_.forEach(domains, d => {
let firstChar = d[0]
if (!!domainMap[firstChar]) {
if (!!domainMap[firstChar][d.domainWord]) {
domainMap[firstChar][d.domainWord].push(d.suffixes)
} else {
domainMap[firstChar][d.domainWord] = [d.suffixes]
}
} else {
domainMap[firstChar] = {
[d.domainWord]: [d.suffixes]
}
}
})

return domainMap
}

Hàm so sánh và insert vào DB

const compareAndImport = async (domains4MillionsFromFiles, domainMap) => {
const bulkPrepare = initBulkInsert()
_.forEach(domains4MillionsFromFiles, d => {
let domainWord = d.replace(/(\.com)?\.vn/g, '')
let suffixes = domainMap[d[0][domainWord]]
if (!!suffixes) {
bulkPrepare.insert({
domain: d,
suffixes: suffixes
})
}
})

return bulkPrepare.execute()
}

Ngôn ngữ sử dụng

  • NodeJs v8 -> Code web chính
  • Go Lang v1.8x -> Một vài hàm import excel, download file, ...

Kết quả

10s ~ 20s

Mẫu DB

MY_DOMAIN_L

domaindomainWord
lucduong.com.vnlucduong
ltv.vnltv

DB_RESULT

domainsuffixes
ltv[.ltv]

Kết luận

Một bài viết mang tính chất giải trí. :)

· 10 min read
Luc Duong

Tôi bắt đầu từ đâu?

Tôi bắt đầu con đường lập trình của mình bằng Java & lập trình web dùng PHP. Sau đó tôi không dùng PHP nữa mà chuyển sang dùng Java Web, tôi chủ yếu sử dụng framework Spring MVC bởi vì công ty tôi chỉ dùng Spring MVC.

Đằng đẵng 3 năm trôi đi, tôi vùi đầu trong J2EE application và tôi cũng tự viết cho mình được một Java Web Framework, thứ mà cho đến bây giờ tôi không còn dùng nữa. Thực sự, tôi chỉ dùng nó cho một dự án tầm trung, sau đó là bỏ đi luôn. Khá là buồn nhưng cũng phải chấp nhận. Lý do tôi bỏ đi là vì khách hàng của tôi khá nhiều, đa số họ là khách hàng vừa và nhỏ, để triển khai các dự án kiểu như vậy quả thực khó với tôi, với khách hàng và khó cả với đồng đội của tôi... Tưởng chừng Java là thứ miễn phí nên chi phí đầu tư thấp nhưng không, thực sự tôi phải bỏ ra khá nhiều chi phí đầu tư cho hosting, cái cần để hỗ trợ cho Java Web. Và nó không rẻ...

Chi phí không phải là cái duy nhất quyết định đến việc tôi từ bỏ Java Web, còn nhiều thứ khác như, ngôn ngữ lập trình, cách lập trình, cách triển khai một dự án, ... tôi sẽ cố gắng nói chi tiết hơn ở một bài viết khác. :D

Từ nhiều lý do ngớ ngẩn nhất có thể, tôi đi tìm một con đường khác để mang về cho mình nhiều tiền hơn, tốn ít chi phí cho lập trình, triển khai và bảo trì hơn.

Tôi gặp NodeJS thế nào?

Cũng sẽ chẳng có gì nếu không gặp thằng bạn dở hơi của tôi, nó là trùm Javascript và dĩ nhiên nó tôn sùng NodeJS. Và nó giới thiệu cho tôi biết rằng NodeJS rất nhẹ, dễ viết, nó là Javascript, có thể xử lý cả triệu request nhẹ nhàng =)), tốn ít ram, phần cứng thấp, dễ triển khai, dễ bảo trì, v.v...

OK. Quất liền. :)

Tiếp cận NodeJS có đơn giản?

Đơn giản hơn tôi nghĩ các bạn ạ. Đơn giản đến độ không thể đơn giản hơn...

1. Cài Node & npm

NODE là cái quái gì nhỉ, lúc đấy tôi cũng chả biết đâu, cứ nghĩ đại nó giống Java JDK, thế là cứ cài đại nó vô. Còn npm, giời ơi, mệt quá cơ. Lắm thứ phải học dữ trời.

Nhưng không, nó đơn giản lắm. Tôi đang cần tìm thư viện xxx thì tôi chỉ cần gõ 1 dòng lệnh đơn giản: npm install xxx, done!! Không cần phải google xxx.jar rồi download package đó từ trên maven repository rồi lại đẩy vào trong thư mục lib của project.

:D Đại khái, nó giống maven hay gradle nếu ai đã xài qua.

2. Tạo project mới

hmm... Nghe có vẻ khó khăn, chắc phải kiếm cái IDE nào ngon ngon, rồi Tool -> New Project -> Project Type -> Project Name -> Framework -> Blah, ... Haha. :D Đơn giản hơn thế, Thích cái gì thì tạo cái đấy thôi. =))

npm init Vâng, khởi tạo một dự án mới bằng command line, nghe có vẻ ngầu ha... Đơn giản quá. Và cái project của tôi nhìn như thế này đây.

--
- package.json

Có 1 file duy nhất. Are you kidding me? Lúc đầu tôi tưởng nó sẽ hầm hố lắm, ai dè có mỗi file json con con. -> Thất vọng ê chề.

{
"name": "demo-nodejs",
"version": "1.0.0",
"description": "Demo NodeJS",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Luc Duong",
"license": "MIT"
}

3. Ví dụ kinh điển trên index.js

Tôi đã tạo file index.js và viết Helloworld example xem nó chạy như thế nào. :v

╭─luc@Duongs-MacBook-Pro  ~/Projects/demo-nodejs
╰─$ vi index.js
╭─luc@Duongs-MacBook-Pro ~/Projects/demo-nodejs
╰─$ node index.js

-> output

Hello World

Cái file nó nhìn thế này đây

console.log("Hello World");

Chỉ có một dòng duy nhất. :( Haha. Great!!

4. Dạo chơi với Express framework.

Đúng là chỉ có vài dòng thôi... Haha. Trước tiên tôi cài nodejs Express framework.

npm install express --save

Sau đó sửa lại file index.js

var express = require('express')
var app = express()

app.get('/', function (req, res) {
res.send('Hello World!')
})

app.listen(3000, function () {
console.log('Example app listening on port 3000!')
})

Và kết quả thì thật bất ngờ. :D

5. Vận dụng những gì mình đã biết

Vâng, vận dụng những gì tôi đã biết ở Java Web và kiến thức căn bản của lập trình web, tôi đã tiếp cận NodeJS nhanh hơn rất nhiều. Xem nào...

    1. Khi người dùng duyệt web http://domain.com/ thì chuyện gì sẽ xảy ra nhỉ? Ai là người xử lý và kết quả trả ra là gì?
    1. Từ câu hỏi trên, tôi mới suy nghĩ đến DispatchServlet trong Java, tôi cần phải tạo một HttpServletHandlermapping với đường dẫn / để sau này, khi người dùng vào web / thì nó sẽ hiểu và đưa cho ServletHandler của tôi đã tạo ra xử lý. Vậy trong Express framework, tôi làm thế nào?
    1. Here we are... (Cái file index.js này có vẻ là khởi nguồn của mọi câu chuyện. haha)
app.get('/', function (req, res) {
res.send('Hello World!')
})
    1. Tức là tôi xử lý ngay trong cái anonymous function? Really? Tôi tưởng là vậy, và tôi phải tìm xem nếu tôi muốn xử lý nó ở file khác và chỉ config ở đây thì làm thế nào.
    1. Tạo một file mới mang tên: HelloController.js trong thư mục nodejs
    1. Viết 1 hàm mang tên getHelloViewexport hàm đó ra ngoài.
const getHelloView = function(req, res) {
return res.send("Hello World!!");
};
exports.getHelloView = getHelloView;
    1. Quay lại index.js include file HelloController.js vào và sửa lại một chút. :D
var app = express()
var HelloController = require('./controller/HelloController') // Chỗ này tôi include file HelloController.js

app.get('/', HelloController.getHelloView) // Chỗ này tôi nói rằng, tôi muốn giao cho hàm getHelloView trong HelloController xử lý đường dẫn '/'

app.listen(3000, function () {
console.log('Example app listening on port 3000!')
})
    1. Vậy là xong cái đầu tiên, get view đơn giản quá, nên tôi nghĩ chắc post cũng tương tự. (rofl) Yes, I think so, and It' true. haha.
    1. Vấn đề tiếp tục nảy sinh, tôi mới chỉ đưa ra cái chữ Hello World!! chết tiệt chứ có cái view html gì đâu, rồi còn template và còn page content nữa. Làm thế nào bây giờ. Nó làm tôi nghĩ đến phải có view resolver. Đúng rồi, phải search google xem làm thế nào để config view resolver, rồi layout, :D
    1. Thực tế chả đâu xa, tôi lên trang chủ của express và vào mục document tìm đến tempalte engine thì nó được kết quả thế này app.set('view engine', 'pug') // thêm dòng này vào trong file index.js app.set('views', './views') // chỗ này chỉ định thư mục chứa file view Trước đó cũng không quên cài gói html engine vào :D npm install pug --save
    1. Tạo thư mục views rồi tạo file hello.pug (Thực chất .pug cũng chả khác gì .html đâu, trong đó là html:D)
--
- controller
- HelloController.js
- node_modules
- views
- hello.pug
- package.json
- index.js

Nội dung file hello.pug

html
head
title Hello World
body
h1 Hello World!!

Sửa lại HelloController.js

var getHelloView = function(req, res) {
return res.render('hello'); // Sửa có dòng này à. Ý là không có send text như lúc đầu nữa, bây giờ render cái file hello.pug ra và trả về cho trình duyệt. :D
};
exports.getHelloView = getHelloView;
    1. OK. Chạy, kết quả đẹp như mơ. Tôi đã có kết quả như ý muốn khi tách file html riêng ra, file controller riêng ra.
    1. Tuy nhiên mỗi một view (UI hay page) đều phải html rồi head rồi body à, lỡ include một đống js vào thì chả lẽ file nào cũng phải copy, mang qua, dán vào. Ngu xuẩn!! =))
    1. Tôi nghĩ ngay đến layout. Bất chợt tôi tạo file layout.pug Làm giống ASP.net thì phải. =))
html
head
title Hello World
body
h1 Here is the header
hr
block content
hr
h1 Here is the footer

Tiếp đến, sửa lại file hello.pug

extends ./layout
block content
h1 Here is the content of Hello World Page

Chạy lại server thôi node index.js

    1. Kết quả có vẻ như ý muốn của tôi, lúc này tôi đã hoàn thành việc layout cho cái web của mình rồi, cứ thế mà chiến tiếp thôi. :D Cơ mà, lại có vấn đề. Làm sao để đưa dữ liệu xuống view nhỉ? hmm... phải có cách chứ, và tôi tiếp tục sửa một vài thứ như thế này: File HelloController.js -> Hẳn là file này rồi, vì chỗ này gọi render cái view mà, không nó thì ai. haha.
var getHelloView = function(req, res) {
return res.render('hello', {title: 'Title cua page', user:{name: 'Luc Duong', email: 'luc@ltv.vn'}}); // Tôi chỉ nghĩ là tôi muốn đưa 1 object trong đó có 2 props `title` và `user` (Giống Javascript bình thường thôi)
};
exports.getHelloView = getHelloView;

Tiếp đến file hello.pug -> Lúc này tôi muốn dùng cái user name & user email đây.

extends ./layout
block content
h1 Hi, #{user.name} your email is #{user.email}

Còn cái title tôi muốn đưa vào cái layout.pug và kết quả sau khi node index.js thì đúng là tuyệt vời.

    1. Cứ thế, tôi chơi với NodeJS trong 1 tiếng đồng hồ thì tôi biết cách sử dụng NodeJSExpress framework.
    1. Những ngày sau tôi tìm hiểu thêm nhiều thứ như làm sao để giải quyết các file tĩnh css, js, fonts, images, ... Hay làm sao để sử dụng MySQL database, MongoDB, hoặc làm sao để đọc, ghi file, ...
    1. Sau đó tôi tìm hiểu đến Middleware, Authentication, ...

Làm cách nào mà tôi lại tìm hiểu nhanh đến vậy?

  • Thực tế thì tôi dùng các câu hỏi như thế này trên google:

    • What is NodeJS? -> Đọc sơ sơ bằng tiếng việt rồi tiếng anh
    • What is express framework? -> Ngắm nghía sơ sơ rồi mãi sau này mới đọc sâu hơn về mỗi cái mình cần
    • How to write the 'HelloWorld' example by using NodeJS? -> Ờ, nó giống cái trên tôi nói
    • How to config route nodejs? -> Đơn giản, chả cần đúng ngữ pháp cũng ra một đống, lướt xem vài cái ví dụ. :D
    • How to handle url in nodejs? -> thậm chí có thể hỏi câu này nếu mù về routing.
    • View resolver in NodeJS -> À há. Câu này cũng đơn giản
    • How to parse data in view nodejs -> :D Hí hí.
    • How to secure nodejs express -> Chắc là Authentication Chủ yếu là mấy câu hỏi how. :D
  • Ngoài ra, bằng một vài kiến thức cơ bản về web như sau:

    • Khi người dùng vào link nào đó thì chắc chắn là phải có một đối tượng xử lý. VD: vào /users thì phải có UserController.getUsers xử lý. :D, giờ chỉ cần tìm hiểu làm sao làm như vậy trong NodeJS
    • Mỗi một request thì phải có response. Tức là cần tìm hiểu thêm, làm sao để lấy thông tin từ request (VD: lấy từ query, lấy từ body). Còn response thì trả về html hay json hay xml hoặc là file :)

Vậy thôi à.