035.3 Bài 1
Chứng chỉ: |
Web Development Essentials |
---|---|
Phiên bản: |
1.0 |
Chủ đề: |
035 Lập trình Máy chủ Node.js |
Mục tiêu: |
035.3 Khái niệm cơ bản về SQL |
Bài: |
1 trên 1 |
Giới thiệu
Mặc dù có thể viết các hàm riêng để thực hiện lưu trữ liên tục, có thể sẽ thuận tiện hơn khi chúng ta sử dụng hệ thống quản lý cơ sở dữ liệu để tăng tốc độ phát triển và đảm bảo tính bảo mật cũng như ổn định cho dữ liệu dạng bảng. Chiến lược phổ biến nhất để lưu trữ dữ liệu được tổ chức trong các bảng có mối liên hệ với nhau (đặc biệt là khi các bảng đó được truy vấn và cập nhật liên tục) là cài đặt cơ sở dữ liệu quan hệ có hỗ trợ Ngôn ngữ Truy vấn có Cấu trúc (SQL) - một ngôn ngữ hướng đến cơ sở dữ liệu quan hệ. Node.js có hỗ trợ nhiều hệ thống quản lý cơ sở dữ liệu SQL khác nhau. Theo các nguyên tắc về tính di động và thực thi không gian người dùng được Node.js Express chấp nhận, SQLite là một lựa chọn thích hợp để lưu trữ dữ liệu liên tục được sử dụng bởi loại máy chủ HTTP này.
SQL
Ngôn ngữ Truy vấn có Cấu trúc được dành riêng cho cơ sở dữ liệu. Các thao tác viết và đọc được thể hiện trong các câu gọi là câu lệnh và truy vấn. Cả câu lệnh và truy vấn đều được tạo thành từ các mệnh đề xác định các điều kiện để thực hiện thao tác.
Ví dụ: tên và địa chỉ email có thể được lưu trữ trong bảng cơ sở dữ liệu chứa các trường name
và email
. Một cơ sở dữ liệu có thể chứa nhiều bảng; vì vậy, mỗi bảng phải có một cái tên độc nhất. Nếu chúng ta sử dụng tên contacts
(liên hệ) cho bảng tên và email, một bản ghi mới có thể được chèn với câu lệnh sau:
INSERT INTO contacts (name, email) VALUES ("Carol", "carol@example.com");
Câu lệnh chèn này bao gồm mệnh đề INSERT INTO
; mệnh đề này xác định bảng và các trường nơi dữ liệu sẽ được chèn vào. Mệnh đề VALUES
sẽ thiết lập các giá trị sẽ được chèn. Tuy không cần phải viết hoa các mệnh đề nhưng đây là một thông lệ phổ biến để có thể nhận dạng các từ khóa SQL trong một câu lệnh hoặc truy vấn một cách hiệu quả hơn.
Một truy vấn trên bảng liên hệ cũng được thực hiện theo cách tương tự nhưng sẽ sử dụng mệnh đề SELECT
:
SELECT email FROM contacts;
dave@example.com
carol@example.com
Trong trường hợp này, mệnh đề SELECT email
sẽ chọn một trường từ các mục trong bảng contacts
. Mệnh đề WHERE
sẽ hạn chế truy vấn đối với các hàng cụ thể:
SELECT email FROM contacts WHERE name = "Dave";
dave@example.com
SQL có nhiều mệnh đề khác nhau và chúng ta sẽ xem xét một số mệnh đề đó trong các phần sau. Trước tiên, ta cần phải hiểu về cách tích hợp cơ sở dữ liệu SQL với Node.js.
SQLite
SQLite có lẽ là giải pháp đơn giản nhất để kết hợp các tính năng của cơ sở dữ liệu SQL vào một ứng dụng. Không giống như các hệ thống quản lý cơ sở dữ liệu phổ biến khác, SQLite không phải là máy chủ cơ sở dữ liệu mà máy khách kết nối tới. Thay vào đó, SQLite sẽ cung cấp một tập hợp các hàm cho phép nhà phát triển tạo cơ sở dữ liệu giống như một tệp thông thường. Trong trường hợp máy chủ HTTP được triển khai bằng Node.js Express, tệp này thường sẽ nằm trong cùng một thư mục với tệp lệnh máy chủ.
Trước khi sử dụng SQLite trong Node.js, ta cần cài đặt mô-đun sqlite3
. Hãy chạy lệnh sau trong thư mục cài đặt của máy chủ - tức là thư mục chứa tệp lệnh Node.js mà ta sẽ chạy.
$ npm install sqlite3
Hãy lưu ý rằng có một số mô-đun có hỗ trợ SQLite, chẳng hạn như better-sqlite3
với cách sử dụng hơi khác so với sqlite3
. Các ví dụ trong bài học này đều dành cho mô-đun sqlite3
; vì vậy, chúng có thể sẽ không hoạt động được như mong đợi nếu bạn chọn một mô-đun khác.
Mở Cơ sở Dữ liệu
Để minh họa cách máy chủ Node.js Express làm việc với cơ sở dữ liệu SQL, hãy viết một tệp lệnh lưu trữ và hiển thị các thông báo được gửi từ một máy khách được xác định bằng cookie. Thông báo được gửi bởi máy khách thông qua phương thức HTTP POST và phản hồi của máy chủ có thể được định dạng dưới dạng JSON hoặc HTML (từ mẫu) tùy thuộc vào định dạng mà máy khách yêu cầu. Bài học này sẽ không đi sâu vào chi tiết về cách sử dụng các phương thức, cookie và mẫu HTTP. Các đoạn mã ở đây sẽ giả định rằng bạn đã có máy chủ Node.js Express nơi các tính năng này đã được định cấu hình và khả dụng.
Cách đơn giản nhất để lưu trữ các thông báo do máy khách gửi là lưu trữ chúng trong một mảng toàn cầu mà trong đó, mỗi thông báo đã gửi trước đó sẽ được liên kết với một khóa nhận dạng duy nhất cho mỗi máy khách. Khóa này có thể được gửi đến máy khách dưới dạng cookie; cookie này sẽ được trình cho máy chủ khi có yêu cầu truy xuất các tin nhắn trước đó trong tương lai.
Tuy nhiên, phương pháp này có một điểm yếu: bởi các thông báo chỉ được lưu trữ trong một mảng toàn cục, tất cả các thông báo sẽ bị mất khi phiên máy chủ hiện tại kết thúc. Đây là một trong những lợi thế khi làm việc với cơ sở dữ liệu vì dữ liệu sẽ được lưu trữ liên tục và không bị mất nếu máy chủ được khởi động lại.
Bằng cách sử dụng tệp index.js
làm tệp lệnh máy chủ chính, chúng ta có thể kết hợp mô-đun sqlite3
và chỉ ra tệp đóng vai trò là cơ sở dữ liệu như sau:
const sqlite3 = require('sqlite3')
const db = new sqlite3.Database('messages.sqlite3');
Nếu nó chưa tồn tại, tệp messages.sqlite3
sẽ được tạo trong cùng một thư mục với tệp index.js
. Bên trong tệp duy nhất này, tất cả các cấu trúc và dữ liệu tương ứng sẽ được lưu trữ. Tất cả các hoạt động cơ sở dữ liệu được thực hiện trong tệp lệnh sẽ được trung gian bởi hằng số db
, tức là tên được đặt cho đối tượng sqlite3
mới sẽ mở tệp messages.sqlite3
.
Cấu trúc của một Bảng
Không có dữ liệu nào có thể được chèn vào cơ sở dữ liệu cho đến khi ít nhất một bảng được tạo. Các bảng có thể được tạo bằng câu lệnh CREATE TABLE
:
db.run('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid CHAR(36), message TEXT)')
Phương thức db.run()
được sử dụng để thực thi các câu lệnh SQL trong cơ sở dữ liệu. Bản thân câu lệnh sẽ được viết dưới dạng tham số cho phương thức. Mặc dù các câu lệnh SQL phải kết thúc bằng dấu chấm phẩy khi được nhập vào bộ xử lý dòng lệnh nhưng nó không bắt buộc trong các câu lệnh được truyền dưới dạng tham số trong một chương trình.
Vì phương thức run
sẽ được thực hiện mỗi khi tệp lệnh được thực thi với node index.js
nên câu lệnh SQL sẽ bao gồm mệnh đề điều kiện IF NOT EXISTS
(NẾU KHÔNG TỒN TẠI) để tránh lỗi trong các lần thực thi sau này khi bảng messages
đã tồn tại.
Các trường tạo nên bảng message
là id
, uuid
và message
. Trường id
là một số nguyên duy nhất được sử dụng để xác định từng mục nhập trong bảng; do đó, trường này sẽ được tạo dưới dạng PRIMARY KEY
(KHOÁ CHÍNH). Khóa chính không được để trống và không thể có hai khóa chính giống hệt nhau trong cùng một bảng. Do đó, hầu hết mọi bảng SQL đều có một khóa chính để theo dõi nội dung của bảng. Mặc dù có thể chọn giá trị cho khóa chính của bản ghi mới một cách rõ ràng (với điều kiện là nó chưa tồn tại trong bảng), sẽ thuận tiện hơn cho chúng ta nếu khóa được tạo tự động. Cờ AUTOINCREMENT
trong trường id
được sử dụng cho mục đích này.
Note
|
Việc cài đặt một cách rõ ràng khóa chính trong SQLite là tùy chọn vì bản thân SQLite sẽ tự động tạo khóa chính. Như đã nêu trong tài liệu SQLite: “Trong SQLite, các hàng của bảng thường có một số nguyên có dấu 64 bit |
Các trường uuid
và message
lần lượt lưu trữ nhận dạng khách hàng và nội dung thông báo. Trường loại CHAR(36)
lưu trữ một lượng cố định 36 ký tự và trường loại TEXT
lưu trữ văn bản có độ dài tùy ý.
Nhập Liệu
Chức năng chính của máy chủ trong ví dụ là lưu trữ các thông báo được liên kết với máy khách đã gửi chúng. Máy khách gửi thông báo trong trường message
trong phần nội dung của yêu cầu được gửi bằng phương thức HTTP POST. Thông tin nhận dạng của khách hàng nằm trong cookie có tên uuid
. Với thông tin này, chúng ta có thể viết tuyến Express để chèn thông báo mới vào cơ sở dữ liệu:
app.post('/', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined )
uuid = uuidv4()
// Insert new message into the database
db.run('INSERT INTO messages (uuid, message) VALUES (?, ?)', uuid, req.body.message)
// If an error occurrs, err object contains the error message.
db.all('SELECT id, message FROM messages WHERE uuid = ?', uuid, (err, rows) => {
let expires = new Date(Date.now());
expires.setDate(expires.getDate() + 30);
res.cookie('uuid', uuid, { expires: expires })
if ( req.headers.accept == "application/json" )
res.json(rows)
else
res.render('index', {title: "My messages", rows: rows})
})
})
Lần này, phương thức db.run()
sẽ thực thi một câu lệnh chèn. Hãy lưu ý rằng uuid
và req.body.message
sẽ không được ghi trực tiếp vào dòng câu lệnh. Thay vào đó, các dấu chấm hỏi đã được thay thế cho các giá trị. Mỗi dấu chấm hỏi sẽ tương ứng với một tham số theo sau câu lệnh SQL trong phương thức db.run()
.
Sử dụng dấu chấm hỏi làm trình giữ chỗ trong câu lệnh được thực thi trong cơ sở dữ liệu sẽ giúp SQLite dễ dàng phân biệt giữa các phần tử tĩnh của câu lệnh và dữ liệu biến của nó. Chiến lược này cho phép SQLite thoát hoặc làm sạch nội dung biến (là một phần của câu lệnh) và ngăn chặn một vi phạm bảo mật phổ biến được gọi là Lỗ hổng truy vấn SQL (SQL injection). Trong vi phạm này, những người dùng độc hại sẽ chèn các câu lệnh SQL vào dữ liệu biến với hy vọng rằng các câu lệnh sẽ được thực thi một cách vô tình. Việc làm sạch sẽ ngăn chặn cuộc tấn công bằng cách vô hiệu hóa các ký tự nguy hiểm trong dữ liệu.
Truy vấn
Như được minh hoạ trong mã mẫu, mục đích của chúng ta là sử dụng cùng một tuyến để chèn thông báo mới vào cơ sở dữ liệu và để tạo danh sách các thông báo đã gửi trước đó. Phương thức db.all()
sẽ trả về tập hợp tất cả các mục trong bảng khớp với tiêu chí được xác định trong truy vấn.
Không giống như các câu lệnh do db.run()
thực hiện, db.all()
sẽ tạo ra một danh sách các bản ghi được xử lý bởi hàm mũi tên được chỉ định trong tham số cuối cùng:
(err, rows) => {}
Hàm này cũng sẽ nhận hai tham số: err
và rows
. Tham số err
sẽ được sử dụng nếu xảy ra lỗi ngăn cản việc thực hiện truy vấn. Khi thành công, tất cả các bản ghi đều có sẵn trong mảng row
(trong đó, mỗi phần tử đều là một đối tượng tương ứng với một bản ghi từ bảng). Các đặc tính của đối tượng này sẽ tương ứng với các tên trường được chỉ ra trong truy vấn uuid
và message
.
Mảng row
là một cấu trúc dữ liệu JavaScript. Do đó, nó có thể được sử dụng để tạo phản hồi bằng các phương thức do Express cung cấp, chẳng hạn như res.json()
và res.render()
. Khi được hiển thị bên trong mẫu EJS, một vòng lặp thông thường có thể liệt kê tất cả các bản ghi:
<ul>
<% rows.forEach( (row) => { %>
<li><strong><%= row.id %></strong>: <%= row.message %></li>
<% }) %>
</ul>
Thay vì lấp đầy mảng rows
bằng tất cả các bản ghi do truy vấn trả về, trong một số trường hợp, việc xử lý từng bản ghi riêng lẻ bằng phương thức db.each()
có lẽ sẽ thuận tiện hơn. Cú pháp phương thức db.each()
cũng tương tự như phương thức db.all()
, nhưng tham số row
trong (err, row) ⇒ {}
sẽ chỉ khớp với một bản ghi tại mỗi một thời điểm.
Thay đổi Nội dung của Cơ sở Dữ liệu
Cho đến nay, máy khách của chúng ta chỉ có thể thêm và truy vấn thông báo trên máy chủ. Vì máy khách hiện đã biết id
của các thông báo đã gửi trước đó nên chúng ta có thể triển khai một chức năng để sửa đổi một bản ghi cụ thể. Thông báo đã sửa đổi cũng có thể được gửi đến một tuyến phương thức HTTP POST, nhưng lần này là với một tham số tuyến để nhận ra id
do máy khách cung cấp trong đường dẫn yêu cầu:
app.post('/:id', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined ){
uuid = uuidv4()
// 401 Unauthorized
res.sendStatus(401)
}
else {
// Update the stored message
// using named parameters
let param = {
$message: req.body.message,
$id: req.params.id,
$uuid: uuid
}
db.run('UPDATE messages SET message = $message WHERE id = $id AND uuid = $uuid', param, function(err){
if ( this.changes > 0 )
{
// A 204 (No Content) status code means the action has
// been enacted and no further information is to be supplied.
res.sendStatus(204)
}
else
res.sendStatus(404)
})
}
})
Tuyến này trình bày cách sử dụng các mệnh đề UPDATE
và WHERE
để sửa đổi một bản ghi hiện có. Một điểm khác biệt quan trọng so với các ví dụ trước đó là việc sử dụng tham số đã được đặt tên, trong đó, các giá trị được gộp vào một đối tượng duy nhất (param
) và được truyền đến phương thức db.run()
thay vì chỉ định riêng từng giá trị. Trong trường hợp này, tên trường (đứng trước $
) sẽ là đặc tính của đối tượng. Tham số đã được đặt tên sẽ cho phép sử dụng tên trường (đứng trước $
) làm trình giữ chỗ thay vì dấu chấm hỏi.
Một câu lệnh giống như câu lệnh trong ví dụ sẽ không gây ra bất kỳ thay đổi nào đối với cơ sở dữ liệu nếu điều kiện do mệnh đề WHERE
áp đặt không khớp với một số bản ghi trong bảng. Để đánh giá xem có bất kỳ bản ghi nào đã bị sửa đổi bởi câu lệnh hay không, hàm gọi lại có thể được sử dụng làm tham số cuối cùng của phương thức db.run()
. Bên trong hàm, ta có thể truy vấn số lượng bản ghi đã thay đổi từ this.changes
. Hãy lưu ý rằng các hàm mũi tên không thể được sử dụng trong trường hợp này bởi chỉ các hàm thông thường có dạng function(){}
mới xác định được đối tượng this
.
Việc xóa một bản ghi rất giống với việc sửa đổi nó. Ví dụ: chúng ta có thể tiếp tục sử dụng tham số tuyến :id
để xác định thông báo cần bị xóa, nhưng lần này là trong một tuyến được gọi bởi phương thức HTTP DELETE của máy khách:
app.delete('/:id', (req, res) => {
let uuid = req.cookies.uuid
if ( uuid === undefined ){
uuid = uuidv4()
res.sendStatus(401)
}
else {
// Named parameters
let param = {
$id: req.params.id,
$uuid: uuid
}
db.run('DELETE FROM messages WHERE id = $id AND uuid = $uuid', param, function(err){
if ( this.changes > 0 )
res.sendStatus(204)
else
res.sendStatus(404)
})
}
})
Bản ghi sẽ bị xóa khỏi bảng với mệnh đề DELETE FROM
. Chúng ta sẽ lại sử dụng chức năng gọi lại để xác định xem có bao nhiêu mục nhập đã bị xóa khỏi bảng.
Đóng Cơ sở Dữ liệu
Sau khi được xác định, đối tượng db
có thể được tham chiếu bất kỳ lúc nào trong quá trình thực thi tệp lệnh vì tệp cơ sở dữ liệu vẫn mở trong suốt phiên hiện tại. Việc đóng cơ sở dữ liệu trong khi tệp lệnh đang chạy thường không quá phổ biến.
Tuy nhiên, chức năng đóng cơ sở dữ liệu lại rất hữu ích trong việc tránh đóng cơ sở dữ liệu đột ngột khi quá trình máy chủ kết thúc. Mặc dù hiếm khi xảy ra nhưng việc tắt cơ sở dữ liệu đột ngột có thể dẫn đến sự không nhất quán nếu dữ liệu trong bộ nhớ chưa được chuyển giao cho tệp. Chẳng hạn, việc tắt cơ sở dữ liệu đột ngột dẫn đến mất dữ liệu có thể xảy ra nếu tệp lệnh bị người dùng kết thúc bằng cách nhấn phím tắt Ctrl+C.
Trong trường hợp sử dụng Ctrl+C vừa được mô tả, phương thức process.on()
có thể chặn các tín hiệu do hệ điều hành gửi và thực hiện việc tắt có trật tự cả cơ sở dữ liệu và máy chủ:
process.on('SIGINT', () => {
db.close()
server.close()
console.log('HTTP server closed')
})
Phím tắt Ctrl+C sẽ gọi tín hiệu hệ điều hành SIGINT
; tín hiệu này sẽ chấm dứt chương trình tiền cảnh trong cửa sổ dòng lệnh. Trước khi kết thúc quá trình khi nhận được tín hiệu SIGINT
, hệ thống sẽ gọi hàm gọi lại (tham số cuối cùng trong phương thức process.on()
). Bên trong chức năng gọi lại, ta có thể đặt bất kỳ mã làm sạch nào, cụ thể là phương thức db.close()
để đóng cơ sở dữ liệu và server.close()
để tự đóng phiên bản Express một cách gọn gàng.
Bài tập Hướng dẫn
-
Mục đích của khóa chính trong bảng cơ sở dữ liệu SQL là gì?
-
Sự khác biệt giữa truy vấn bằng cách sử dụng
db.all()
vàdb.each()
là gì? -
Tại sao việc sử dụng trình giữ chỗ và không bao gồm dữ liệu do máy khách gửi trực tiếp trong câu lệnh hoặc truy vấn SQL lại quan trọng?
Bài tập Mở rộng
-
Phương pháp nào trong mô-đun
sqlite3
có thể được sử dụng để chỉ trả về một mục nhập trong bảng ngay cả khi truy vấn khớp với nhiều mục nhập? -
Giả sử mảng
rows
được truyền dưới dạng tham số cho hàm gọi lại và chứa kết quả của một truy vấn được thực hiện vớidb.all()
. Làm cách nào để một trường có tênprice
hiện ở vị trí đầu tiên củarow
có thể được tham chiếu bên trong hàm gọi lại? -
Phương thức
db.run()
thực thi các câu lệnh sửa đổi cơ sở dữ liệu, chẳng hạn nhưINSERT INTO
(CHÈN VÀO). Sau khi chèn một bản ghi mới vào bảng, làm thế nào để có thể truy xuất khóa chính của bản ghi mới được chèn?
Tóm tắt
Bài học này đã nói về cách sử dụng cơ bản của cơ sở dữ liệu SQL trong các ứng dụng Node.js Express. Mô-đun sqlite3
cung cấp một cách đơn giản để lưu trữ dữ liệu liên tục trong cơ sở dữ liệu SQLite, trong đó một tệp duy nhất sẽ chứa toàn bộ cơ sở dữ liệu và không yêu cầu một máy chủ cơ sở dữ liệu chuyên dụng. Bài học này đã đi qua các khái niệm và quy trình sau:
-
Cách thiết lập kết nối cơ sở dữ liệu từ Node.js.
-
Cách tạo một bảng đơn giản và vai trò của các khóa chính.
-
Sử dụng câu lệnh SQL
INSERT INTO
để thêm dữ liệu mới từ bên trong tệp lệnh. -
Truy vấn SQL sử dụng các phương thức SQLite tiêu chuẩn và các hàm gọi lại.
-
Thay đổi dữ liệu trong cơ sở dữ liệu bằng cách sử dụng câu lệnh SQL
UPDATE
vàDELETE
.
Đáp án Bài tập Hướng dẫn
-
Mục đích của khóa chính trong bảng cơ sở dữ liệu SQL là gì?
Khóa chính là trường nhận dạng độc nhất của mỗi bản ghi trong bảng cơ sở dữ liệu.
-
Sự khác biệt giữa truy vấn bằng cách sử dụng
db.all()
vàdb.each()
là gì?Phương thức
db.all()
sẽ gọi hàm gọi lại với một mảng duy nhất chứa tất cả các mục nhập tương ứng với truy vấn. Phương thứcdb.each()
sẽ gọi hàm gọi lại cho mỗi hàng kết quả. -
Tại sao việc sử dụng trình giữ chỗ và không bao gồm dữ liệu do máy khách gửi trực tiếp trong câu lệnh hoặc truy vấn SQL lại quan trọng?
Với trình giữ chỗ, dữ liệu do người dùng gửi sẽ được thoát trước khi được đưa vào truy vấn hoặc câu lệnh. Điều này sẽ cản trở các cuộc tấn công vào lỗ hổng cơ sở dữ liệu SQL mà trong đó, các câu lệnh SQL được đặt bên trong dữ liệu biến nhằm thực hiện các thao tác tùy ý trên cơ sở dữ liệu.
Đáp án Bài tập Mở rộng
-
Phương pháp nào trong mô-đun
sqlite3
có thể được sử dụng để chỉ trả về một mục nhập trong bảng ngay cả khi truy vấn khớp với nhiều mục nhập?Phương thức
db.get()
có cùng cú pháp nhưdb.all()
nhưng chỉ trả về mục nhập đầu tiên tương ứng với truy vấn. -
Giả sử mảng
rows
được truyền dưới dạng tham số cho hàm gọi lại và chứa kết quả của một truy vấn được thực hiện vớidb.all()
. Làm cách nào để một trường có tênprice
hiện ở vị trí đầu tiên củarow
có thể được tham chiếu bên trong hàm gọi lại?Mỗi mục trong
row
là một đối tượng có đăhc tính tương ứng với tên trường cơ sở dữ liệu. Vì vậy, giá trị của trườngprice
trong kết quả đầu tiên sẽ là ởrows[0].price
. -
Phương thức
db.run()
thực thi các câu lệnh sửa đổi cơ sở dữ liệu, chẳng hạn nhưINSERT INTO
(CHÈN VÀO). Sau khi chèn một bản ghi mới vào bảng, làm thế nào để có thể truy xuất khóa chính của bản ghi mới được chèn?Một hàm thông thường có dạng
function(){}
có thể được sử dụng làm hàm gọi lại của phương thứcdb.run()
. Bên trong nó, đặc tínhthis.lastID
sẽ chứa giá trị khóa chính của bản ghi được chèn cuối cùng.