Trong bài viết này, chúng ta sẽ cùng thảo luận về yêu cầu web tĩnh và cách làm việc với đường dẫn thư mục trong NodeJS. Đây là hai khái niệm không mới đối với chúng ta, tuy nhiên khi bắt tay vào việc tự xây dựng một phần mềm server
cho riêng mình thì những yếu tố căn bản này lại trở nên đặc biệt quan trọng. Hãy cùng bắt đầu với các yêu cầu web tĩnh.
Các yêu cầu web tĩnh thì khác gì với những yêu cầu không tĩnh?
Ầy... chuyện dài lắm. Nhưng chúng ta cứ bắt đầu đơn giản thôi. Khi nào nói xong thì là hết chuyện.
Đầu tiên thì là thao tác mà chúng ta vẫn làm hàng ngày, đó là truy cập vào các trang web và tìm kiếm tin tức cập nhật hoặc kiến thức. Chúng ta có thể xuất phát với trang chủ của một trang web rồi nhấn vào một liên kết bài viết nào đó. Một yêu cầu mới được tạo ra và gửi tới máy chủ của trang web nọ và liên kết URL trên thanh địa chỉ của trình duyệt web cũng thay đổi, chúng ta nhận được kết quả phản hồi là một trang đơn mới được thay thế vào trang đơn hiện tại.
Có trang web thì chúng ta thấy các liên kết trên thanh địa chỉ có dạng kết thúc là tên một tệp .html
, ví dụ như trang blog cá nhân mà bạn đang sử dụng trên Github Pages
; Và có những trang web khác thì các liên kết lại ở dạng không liên quan gì tới tên của một tệp HTML, ví dụ như YouTube - https://www.youtube.com/watch?v=mQLvOJqGrcw.
Khi một yêu cầu được gửi về máy chủ với dạng đầu tiên thì chúng ta có thể gọi là yêu cầu web tĩnh. Trường hợp còn lại thì... không nhất thiết phải có tên gọi để phân biệt, chúng ta cứ biết nó không phải là yêu cầu web tĩnh thôi.
Đối với trường hợp yêu cầu web tĩnh thì thông điệp gửi từ trình duyệt web được hiểu chính xác là tôi muốn xem tệp bai-blog-so-1001.html
; Còn trường hợp như của link YouTube kia thì là tôi muốn xem watch
một v
ideo có mã ký hiệu như thế này mQLvOJqGrcw
. Ở đây chúng ta quan tâm tới yêu cầu web tĩnh trước là bởi vì cách thức vận hành đơn giản nhất của một server
đó là chúng ta cứ kết nối yêu cầu xem một tệp tĩnh với một tệp dữ liệu có tên trùng khớp đang lưu trữ trong máy chủ. Và chúng ta cần phải thực hiện được điều này đã rồi mới có thể thả diều suy nghĩ tưởng tượng về những logic hoạt động phức tạp hơn được.
Làm thế nào để xem được nội dung yêu cầu mà trình duyệt web gửi tới?
Trong đoạn code server
mà chúng ta sử dụng từ đầu cho đến giờ có một điểm bị bỏ quên, đó là tham số request
của hàm xử lý yêu cầu handleRequest
.
/* Creating a server */
const http = require('http');
const fsPromises = require('fs/promises');
const path = require('path');
const handleRequest = function(request, response) {
var indexHtml = path.join(__dirname, 'static', 'index.html');
fsPromises.readFile(indexHtml)
.then(function(data) {
response.setHeader('content-type', 'text/html');
response.statusCode = 200;
response.end(data);
})
.catch(function(error) {
throw(error);
});
}; // handleRequest
const server = http.createServer(handleRequest);
/* Start running server ... */
Khi có một yêu cầu gửi tới từ trình duyệt web, server
sẽ tạo ra một object IncomingMessage
trong module http
, và truyền vào vị trí tham số request
của hàm xử lý handleRequest
mà chúng ta đã viết. Và trong tài liệu về module http
thì NodeJS có cung cấp cho chúng ta đủ thứ để truy xuất các thông tin liên quan tới yêu cầu được gửi tới - giao thức truyền tải, phương thức đóng gói thông tin, tiêu đề, tên miền, v.v...
Và sau một lượt nhìn ngó cái Table of contents
của http
thì chúng ta cũng biết được là các object IncomingMessage
có một thuộc tính url
để mô tả đường dẫn được biểu thị trong liên kết gửi yêu cầu từ trình duyệt web. Bây giờ chúng ta sẽ thử thêm thao tác in đường dẫn ra cửa sổ dòng lệnh mỗi khi có yêu cầu gửi tới server
và theo dõi kết quả. Ở đây chúng ta sẽ đặt tạm một biến requestCount
để theo dõi số lượt yêu cầu gửi tới.
/* Creating a server */
...
var requestCount = 0;
const handleRequest = function(request, response) {
requestCount += 1;
console.log(requestCount + ': ' + request.url);
...
Sau khi khởi động lại server
thì chúng ta có thể thử truy cập lại một vài lần với những địa chỉ truy cập giả định tùy ý để xem kết quả tương ứng với các lượt truy cập.
1: /
2: /asset/style.css
3: /asset/main.js
4: /favicon.ico
http://127.0.0.1:3000/post/an-article.html
5: /post/an-article.html
6: /asset/style.css
7: /asset/main.js
8: /favicon.ico
Ồ... như vậy là mỗi lần chúng ta gửi yêu cầu tới, hàm requestHandler
hiện tại đang trả về nội dung của tệp index.html
; Và các thẻ <link>
và <script>
lần lượt gửi tiếp yêu cầu tới để xin tải thêm tệp style.css
và main.js
; Cuối cùng là trình duyệt tự gửi yêu cầu xin tải tệp ảnh favicon.ico
để làm cái biểu tượng trên thanh tab bar.
Yêu cầu chính mà chúng ta nhận được là dòng đầu tiên của mỗi lượt nhập địa chỉ mới để truy cập. Đối với trang chủ thì là /
và đối với lần tiếp theo là /post/an-article.html
. Nếu vậy thì có lẽ là chúng ta cũng đoán ra được rồi, đoạn liên kết url
mà server
nhận được là tính từ vị trí kết thúc cái tên miền giống như trường hợp của cái link YouTube ở phía trên.
Gửi tệp HTML đáp ứng các yêu cầu web tĩnh
[nodejs-blog]
|
+---[static]
| |
| +---[asset]
| | |
| | +---style.css
| | +---main.js
| |
| +---[post]
| | |
| | +---an-article.html
| | +---another-article.html
| |
| +---index.html
| +---oops.html
|
+---server.js
+---test.js
Bây giờ chúng ta sẽ giả định các yêu cầu gửi tới cần xử lý -
- Nếu là
/
thì sẽ gửi trả nội dung của trang chủindex.html
. - Nếu ở dạng
/post/an-article.html
thì sẽ gửi trả nội dung của tệp HTML bài viết blog tương ứng làan-article.html
. - Trong trường hợp không tìm thấy bài viết tương ứng hoặc dạng yêu cầu khác thì sẽ gửi trả nội dung của trang
oops.html
. - Nếu có dạng
/asset/style.css
thì sẽ gửi trả nội dung của tệp hỗ trợ tương ứng trong thư mụcasset
làstyle.css
, và đối vốimain.js
cũng tương tự. - Trong trường hợp không tìm thấy tệp hỗ trợ tương ứng trong thư mục
asset
thì in ra thông báo ngoại lệ ởconsole
củaserver
.
Vậy bây giờ chúng ta cần chuẩn bị thêm nội dung đơn giản cho các tệp - oops.html
, an-article.html
, another-article.html
, style.css
, và main.js
.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Oops ! Not-Found</title>
<link rel="stylesheet" href="/asset/style.css">
</head>
<body>
<h1>
<h1>Oops ! Not-Found</h1>
</h1>
<script src="/asset/main.js"></script>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>An Article</title>
<link rel="stylesheet" href="/asset/style.css">
</head>
<body>
<h1>An Article</h1>
<script src="/asset/main.js"></script>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Another Article</title>
<link rel="stylesheet" href="/asset/style.css">
</head>
<body>
<h1>Another Article</h1>
<script src="/asset/main.js"></script>
</body>
</html>
h1 {
font-size: 90;
line-height: 1.5;
text-align: center;
}
console.log('Client-side JavaScript');
Nếu như chúng ta có thể tìm và gửi trả chính xác các tệp được yêu cầu với dự kiến như trên thì chúng ta sẽ có thể sử dụng blog này giống như cách sử dụng Github Pages
cơ bản; Và như vậy là trang blog của bạn sẽ có thể chuyển nội dung sang Glitch
dần dần trong thời gian chúng ta tiếp tục học các logic xử lý phức tạp hơn.
Bây giờ chúng ta sẽ xem lại đoạn code server
mà chúng ta đã có và đặt một chút suy nghĩ cho hàm xử lý yêu cầu handleRequest
.
/* Creating a server */
const http = require('http');
const fsPromises = require('fs/promises');
const path = require('path');
const handleRequest = function(request, response) {
var indexHtml = path.join(__dirname, 'static', 'index.html');
fsPromises.readFile(indexHtml)
.then(function(data) {
response.setHeader('content-type', 'text/html');
response.statusCode = 200;
response.end(data);
})
.catch(function(error) {
throw(error);
});
}; // handleRequest
const server = http.createServer(handleRequest);
/* Start running server */
const port = 3000;
const hostname = '127.0.0.1';
const callback = function() {
console.log('Server is running at...');
console.log('http://' + hostname + ':' + port + '/');
}; // callback
server.listen(port, hostname, callback);
Như vậy là chúng ta có tất cả các kiểu yêu cầu gửi tới được tập trung tiếp nhận tại handleRequest
. Với mỗi đối tượng dữ liệu được yêu cầu là các nội dung bài viết /post
hay các nội dung hỗ trợ /asset
thì chúng ta sẽ cần phân chia lộ trình xử lý riêng.
Với mỗi kiểu tệp dữ liệu khác nhau thì khi phản hồi lại, chúng ta cũng sẽ phải thiết lập tiêu đề response.setHeader
với kiểu nội dung content-type
phù hợp để thông báo cho trình duyệt web. Ví dụ như text/html
cho nội dung code HTML, text/css
cho nội dung code CSS, hoặc text/javascript
....
Lúc này tổng quan logic hoạt động của hàm handleRequest
về cơ bản là một cấu trúc điều kiện if
dựa trên nội dung của request.url
mà chúng ta nhận được. Như vậy chúng ta có thể chuyển tiếp tác vụ xử lý yêu cầu cho các hàm xử lý tác vụ phụ handleTypeRequest
ở dạng như thế này -
/* Creating a server */
const http = require('http');
const handleRequest = function(request, response) {
if (request.url == '/')
handleHomeRequest(request, response);
else if (request.url.startsWith('/post'))
handlePostRequest(request, response);
else if (request.url.startsWith('/asset'))
handleAssetRequest(request, response);
else
handleOopsRequest(request, response);
}; // handleRequest
/* ... */
Và lúc này chúng ta sẽ có thể di chuyển tất cả phần code xử lý yêu cầu chi tiết ra một tệp route.js
bên ngoài ở dạng một module
hỗ trợ nhỏ. Ở đây chúng ta cũng sẽ làm quen luôn với cú pháp exports
của CJS thay cho cú pháp của JavaScript cung cấp mặc định.
const handleHomeRequest = function(request, response) {
response.end('Homepage');
}; // handleHomeRequest
const handlePostRequest = function(request, response) {
response.end('Post');
}; // handlePostRequest
const handleAssetRequest = function(request, response) {
response.end('Asset');
}; // handleAssetRequest
const handleOopsRequest = function(request, response) {
response.end('Not found');
}; // handleOopsRequest
// Xuất khẩu các thành phần của module
// muốn chia sẻ cho code bên ngoài sử dụng
module.exports = {
handleHomeRequest,
handlePostRequest,
handleOopsRequest,
handleAssetRequest
}; // exports
Bây giờ chúng ta cần thêm lệnh require
vào test.js
để sử dụng các hàm của route.js
cung cấp. Ở bước này chúng ta có thể viết lại các hàm xử lý trong handleRequest
thành dạng phương thức của object
thu được sau khi require
.
/* Creating a server */
const http = require('http');
const fsPromises = require('fs/promises');
const path = require('path');
const handleRequest = function(request, response) {
var routeJs = path.join(__dirname, 'route.js');
var route = require(routeJs);
if (request.url == '/')
route.handleHomeRequest(request, response);
/* ... */
Hãy khởi động lại server
và thử truy cập với các dạng liên kết request.url
để xem logic điều hành của handleRequest
đã hoạt động ổn thỏa chưa.
http://127.0.0.1:3000/post/an-article.html
http://127.0.0.1:3000/asset/style.css
http://127.0.0.1:3000/something-else
Sau khi đã chắc chắn logic điều hành của hàm handleRequest
hoạt động ổn rồi thì chúng ta bắt đầu viết code chi tiết cho các hàm xử lý tác vụ phụ. Hàm đầu tiên là handleHomeRequest
thì chúng ta chỉ việc copy/paste
code xử lý cũ của handleRequest
là có thể sử dụng được, vì trước đó chúng ta chỉ có thể gửi lại cho người xem duy nhất trang index.html
đối với tất cả mọi yêu cầu.
Tuy nhiên thì ở khối .catch
, trong trường hợp vì lý do nào đó mà chúng ta ko thể truy xuất và đọc được tệp index.html
thì phương án xử lý là thông báo lỗi ra console
thay vì throw
.
const fsPromises = require('fs/promises');
const path = require('path');
const handleHomeRequest = function(request, response) {
var indexHtml = path.join(__dirname, 'static', 'index.html');
fsPromises.readFile(indexHtml)
.then(function(data) {
response.setHeader('content-type', 'text/html');
response.statusCode = 200;
response.end(data);
})
.catch(function(error) {
console.error(error);
});
}; // handleHomeRequest
/* ... */
Như vậy là nội dung của tệp index.html
đã được trả về sau khi chúng ta gửi yêu cầu là /
. Tuy nhiên tệp style.css
vẫn chưa được tải kèm theo, và tệp main.js
hiển nhiên cũng vậy. Chúng ta sẽ xử lý hàm handleAssetRequest
sau đó để khắc phục điểm này.
Bây giờ theo trình tự là tới hàm handlePostRequest
. Về đường dẫn để tìm kiếm tệp thì chúng ta vẫn xuất phát từ thư mục static
và chỉ cần thay thế chuỗi 'index.html'
bằng request.url
. Trong trường hợp không tìm thấy nội dung hay không đọc được tệp thì chúng ta sẽ cần in thông báo ngoại lệ ra console
, và gửi lại cho người dùng một trang đơn thông báo không tìm thấy nội dung - tức là chuyển quyền điều khiển tới cho hàm handleOopsRequest
.
/* handleHomeRequest... */
const handlePostRequest = function (request, response) {
var postHtml = path.join(__dirname, 'static', request.url);
fsPromises.readFile(postHtml)
.then(function(data) {
response.setHeader('content-type', 'text/html');
response.writeHead(200);
response.end(data);
})
.catch(function(error) {
console.error(error);
handleOopsRequest(request, response);
});
}; // handlePostRequest
/* ... */
http://127.0.0.1:3000/post/an-article.html
http://127.0.0.1:3000/post/another-article.html
Tiếp theo là hàm handleAssetRequest
để gửi kèm các tệp CSS và JavaScript khi được yêu cầu thêm. Ở đây chúng ta có 2 kiểu nội dung trả về là text/css
và text/javascript
. Do đó chúng ta cần xây dựng một hàm nhỏ hỗ trợ để kiểm tra loại tệp được yêu cầu từ request.url
. Bên cạnh đó thì kiểu nội dung text/html
cũng cần gõ lặp lại thủ công vào các hàm khác nhiều lần nên chúng ta cũng sẽ tạo ra một biến tham chiếu để tránh khả năng mắc lỗi typo.
const fsPromises = require('fs/promises');
const path = require('path');
const handleHomeRequest = function(request, response) {
/* ... */
};
const handlePostRequest = function(request, response) {
/* ... */
};
const handleAssetRequest = function(request, response) {
var assetFile = path.join(__dirname, 'static', request.url);
fsPromises.readFile(assetFile)
.then(function(data) {
var contentType = textType.get(request.url);
response.setHeader('content-type', contentType);
response.writeHead(200);
response.end(data);
})
.catch(function(error) {
console.error(error);
});
}; // handleAssetRequest
const handleOopsRequest = function(request, response) {
response.end('Not found');
}; // handleOopsRequest
const textType = {
html: 'text/html',
css: 'text/css',
js: 'text/javascript',
get(url) {
if (url.endsWith('.html'))
return textType.html;
else if (url.endsWith('.css'))
return textType.css;
else if (url.endsWith('.js'))
return textType.js;
else
return '';
}
}; // textType
module.exports = {
handleHomeRequest,
handlePostRequest,
handleOopsRequest,
handleAssetRequest
}; // exports
http://127.0.0.1:3000/post/an-article.html
Như vậy là các tệp style.css
và main.js
đã được tải kèm trang chủ và các trang bài viết. Bây giờ chúng ta chỉ còn hàm handleOopsRequest
để trả về trang đơn thông báo cho người dùng khi không tìm thấy bài viết phù hợp. Ở đây chúng ta sửa lại đường dẫn tìm kiếm tệp và thay request.url
bằng đường dẫn tĩnh /oops.html
. Kiểu nội dung trả về là textType.html
vì chúng ta đã biết trước kiểu tệp trả về; Đồng thời chúng ta cũng cần sửa lại tín hiệu phản hồi là writeHead(404)
thay vì writeHead(200)
để biểu thị lỗi truy vấn.
/* handeAssetRequest... */
const handleOopsRequest = function(request, response) {
var oopsHtml = path.join(__dirname, 'static', 'oops.html');
fsPromises.readFile(oopsHtml)
.then(function(data) {
response.setHeader('content-type', textType.html);
response.writeHead(404);
response.end(data);
})
.catch(function(error) {
console.error(error);
});
}; // handleOopsRequest
/* ... */
http://127.0.0.1:3000/something-else
Kết thúc bài viết
Ở đây mình tổng kết lại code của tệp test.js
và route.js
để bạn có thể so sánh và kiểm tra lại, để phòng trường hợp mình viết bài có nhầm lẫn ở đoạn nào đó mà mình soát lại không thấy.
/* Creating a server */
const http = require('http');
const path = require('path');
const handleRequest = function(request, response) {
var routeJs = path.join(__dirname, 'route.js');
var route = require(routeJs);
if (request.url == '/')
route.handleHomeRequest(request, response);
else if (request.url.startsWith('/post'))
route.handlePostRequest(request, response);
else if (request.url.startsWith('/asset'))
route.handleAssetRequest(request, response);
else
route.handleOopsRequest(request, response);
}; // handleRequest
const server = http.createServer(handleRequest);
/* Start running server */
const port = 3000;
const hostname = '127.0.0.1';
const callback = function() {
console.log('Server is running at...');
console.log('http://' + hostname + ':' + port + '/');
}; // callback
server.listen(port, hostname, callback);
Ở phần code export
các thành phần của module
hỗ trợ route.js
, nếu không muốn phải khai báo lại tên biến của các hàm muốn export
thì chúng ta có thể gán trực tiếp các hàm đó vào thành thuộc tính của object exports
đại diện của module
-
module.exports.handleHomeRequest = function() { /* ... */ };
// --- hoặc
exports.handleHomeRequest = function() { /* ... */ };
Trong đó thì biến rời rạc exports
ở dòng dưới là một lối tắt được NodeJS gán tham chiếu tới object
đang lưu ở thuộc tính module.exports
. Do đó cả 2 cách viết là tương đồng nếu như chúng ta không gán thứ gì đó khác thay thế cho địa chỉ tham chiếu mà thuộc tính module.exports
đang lưu trữ từ đầu hoặc trỏ biến rời rạc exports
đi đâu đó khác.
const fsPromises = require('fs/promises');
const path = require('path');
const handleHomeRequest = function(request, response) {
var indexHtml = path.join(__dirname, 'static', 'index.html');
fsPromises.readFile(indexHtml)
.then(function(data) {
response.setHeader('content-type', textType.html);
response.writeHead(200);
response.end(data);
})
.catch(function(error) {
console.error(error);
});
}; // handleHomeRequest
const handlePostRequest = function(request, response) {
var postHtml = path.join(__dirname, 'static', request.url);
fsPromises.readFile(postHtml)
.then(function(data) {
response.setHeader('content-type', textType.html);
response.writeHead(200);
response.end(data);
})
.catch(function(error) {
console.error(error);
handleOopsRequest(request, response);
});
}; // handlePostRequest
const handleAssetRequest = function(request, response) {
var assetFile = path.join(__dirname, 'static', request.url);
fsPromises.readFile(assetFile)
.then(function(data) {
var contentType = textType.get(request.url);
response.setHeader('content-type', contentType);
response.writeHead(200);
response.end(data);
})
.catch(function(error) {
console.error(error);
});
}; // handleAssetRequest
const handleOopsRequest = function(request, response) {
var oopsHtml = path.join(__dirname, 'static', 'oops.html');
fsPromises.readFile(oopsHtml)
.then(function(data) {
response.setHeader('content-type', textType.html);
response.writeHead(404);
response.end(data);
})
.catch(function(error) {
console.error(error);
});
}; // handleOopsRequest
const textType = {
html: 'text/html',
css: 'text/css',
js: 'text/javascript',
get(url) {
if (url.endsWith('.html'))
return textType.html;
else if (url.endsWith('.css'))
return textType.css;
else if (url.endsWith('.js'))
return textType.js;
else
return '';
}
}; // textType
module.exports = {
handleHomeRequest,
handlePostRequest,
handleAssetRequest,
handleOopsRequest
}; // exports
Như vậy là tới thời điểm hiện tại, chúng ta đã có thể sử dụng code server
đơn giản này để làm blog giống như cách sử dụng Github Pages
cơ bản - tức là tạo ra các bài viết bằng các trang đơn được viết bằng code HTML. Đây cũng chính là điểm mà chúng ta chính thức bắt đầu tìm hiểu cách để tự động hóa việc tạo ra các trang đơn HTML từ một nguồn nội dung nhập liệu dễ hơn đứng từ góc độ của người sử dụng trang web.
Điều này có nghĩa là chúng ta sẽ có thể tạo nội dung bài viết ở một dạng khác ví dụ như các tệp markdown
với cách biểu thị nhanh các nội dung trong khung soạn thảo giống như Viblo
đang sử dụng.
Sau đó những nội dung trong các tệp markdown
sẽ được biên dịch thành code HTML
và gửi thẳng cho trình duyệt web, hoặc lưu lại thành các tệp HTML
tương ứng trong thư mục nodejs-blog/static/post
.
Đối với một tác vụ như thế này, chúng ta đã bắt đầu phải nghĩ tới sự trợ giúp của các thư viện JavaScript hỗ trợ từ bên ngoài; Và trong bài sau, chúng ta sẽ làm quen với cách thức cài đặt và sử dụng các thư viện hỗ trợ trong môi trường NodeJS. Bây giờ thì hãy nghỉ giải lao một chút đã. Hẹn gặp lại bạn trong bài viết tiếp theo.