從0開始打造網頁伺服器

Tommy

延續上週直播主題

今天要用Node.js打造一個靜態網頁伺服器

什麼是靜態網頁伺服器?

不具備後端程式處理的

網頁伺服器

只單純提供html, css, js, jpg......等等這類靜態檔案給瀏覽器

還記得之前有直播過HTTP Header & Status code嗎?

今天終於要派上用場了!!!

什麼是伺服器?

所有的用戶都可以

連接到相同的電腦

伺服器都放在哪裡?

理想的地方

Title Text

實際的地方

感覺的地方

有沒有87%像

因為真的很冷啊

機房的特色

1. 很冷 => 要讓伺服器硬體降溫

2. 很吵 => 機器運轉風扇聲音很大

3. 很多綠色乖乖 => 一種有拜有保佑的概念

4. 24H運作 => 不然就不能半夜逛網站了

千萬不要放五香&巧克力口味

不要不信邪啊

傳統執行後端程式

都需要網頁伺服器

常見的網頁伺服器程式

  • PHP => Apache
  • ASP(.NET) => IIS
  • JSP => Tomcat
  • Nginx
  • ......

那Node.js需要伺服器嗎?

Node.js本身自帶HTTP模組

可以自己啟動一個伺服器

Node.js和傳統伺服器有什麼不一樣?

摩斯 vs 麥當勞

使用HTTP模組前必須要懂

四大重點:Request, Response, IP, Port

request & response

 

IP & Port

127.0.0.1

指向自己電腦的IP位置

localhost也是指向自己電腦的IP

曾經因127.0.0.1鬧過笑話

剛入門後端時貼127.0.0.1的網址給別人

http預設80 Port
https預設443 Port

若不是監聽在預設port就必須自帶port編號

例如:http://192.168.0.1:3000

透過Node.js建立

最簡單的網頁伺服器

var http = require('http');
var server = http.createServer(function(request, response) {
    response.write('hello world');
    response.end();
});
server.listen(3000, '127.0.0.1');

一般執行Node.js程式
跑完就結束了

C:\>type demo.js
console.log('Hello World');

C:\>node demo
Hello World

C:\>_

(回到C:\>游標)

但HTTP啟動監聽後
程式不會停下來

因為若是關閉了,瀏覽器就無法和伺服器連線

所以必須要一直保持監聽狀態,等待瀏覽器連線

C:\>node web.js

(不會跳回C:\>游標)

沒有response.end()會怎樣

瀏覽器不知道什麼時候結束

每一個Port只能

由一支程式監聽

C:\>node web
events.js:183
      throw er; // Unhandled 'error' event
      ^

Error: listen EADDRINUSE :::3000
    at Object._errnoException (util.js:1022:11)
    at _exceptionWithHostPort (util.js:1044:20)
    at Server.setupListenHandle [as _listen2] (net.js:1367:14)
    at listenInCluster (net.js:1408:12)
    at Server.listen (net.js:1492:7)
    at Object.<anonymous> (C:\Users\Tommy\Desktop\demo\web.js:6:8)
    at Module._compile (module.js:652:30)
    at Object.Module._extensions..js (module.js:663:10)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)

回應Content-type

告訴瀏覽器傳送什麼內容

var http = require('http');
var server = http.createServer(function(request, response) {
    response.writeHead(200, {
        'Content-Type': 'text/html'
    })
    response.write('<h1>Hello World</h1>');
    response.end();
});
server.listen(3000);

沒有Content-Type

瀏覽器會自動判斷

但有給Content-Type瀏覽器就會用這個類型解讀

試試看text/plain和image/png和application/zip

Status Code

任何回應都要有一個status code

沒問題就給200 OK

讀取index.html檔案回應

var fs = require('fs');
var http = require('http');
var server = http.createServer(function(request, response) {
    response.writeHead(200, {
        'Content-Type': 'text/html'
    })
    var html = fs.readFileSync('./index.html')
    response.write(html);
    response.end();
});
server.listen(3000);

盡可能使用非同步操作

當大量request進來才不會被同步操作卡住

var fs = require('fs');
var http = require('http');
var server = http.createServer(function(request, response) {
    response.writeHead(200, {
        'Content-Type': 'text/html'
    })
    fs.readFile('./index.html', function(err, html) {
        if (!err) {
            response.write(html);
            response.end();
        }
    });
});
server.listen(3000);

同步 vs 非同步

同步會鎖住Event Loop
非同步會難閱讀程式碼

無論網址輸入什麼都是回應index.html網頁的內容

http://127.0.0.1:3000/index.css

http://127.0.0.1:3000/index.js

看到都是index.html見鬼了

解析要求路徑

/filename.html

/path/filename.txt

request.url

var fs = require('fs');
var http = require('http');
var server = http.createServer(function(request, response) {
    console.log(request.url)
    response.writeHead(200, {
        'Content-Type': 'text/html'
    })
    fs.readFile('./index.html', function(err, html) {
        if (!err) {
            response.write(html);
            response.end();
        }
    });
});
server.listen(3000);

不過要注意一點

會包含GET參數

http://127.0.0.1:3000/index.css?v=1

如何排除GET參數

var url = require('url');
var fs = require('fs');
var http = require('http');
var server = http.createServer(function(request, response) {
    var pathname = url.parse(request.url).pathname;
    console.log(pathname);
    response.writeHead(200, {
        'Content-Type': 'text/html'
    })
    fs.readFile('./index.html', function(err, html) {
        if (!err) {
            response.write(html);
            response.end();
        }
    });
});
server.listen(3000);

取得檔案狀態fs.stat

var url = require('url');
var fs = require('fs');
var http = require('http');
var server = http.createServer(function(request, response) {
    var pathname = url.parse(request.url).pathname;
    console.log(pathname);
    response.writeHead(200, {
        'Content-Type': 'text/html'
    })
    fs.stat('.' + pathname, function(err, stats) {
        console.log(err, stats)
    })
    fs.readFile('./index.html', function(err, html) {
        if (!err) {
            response.write(html);
            response.end();
        }
    });
});
server.listen(3000);

檔案不存在err會有錯誤物件

沒err錯誤物件還是有例外

也有可能是資料夾而不是檔案

stats.isFile()

檢查是不是檔案

var url = require('url');
var fs = require('fs');
var http = require('http');
var server = http.createServer(function(request, response) {
    var pathname = url.parse(request.url).pathname;
    fs.stat('.' + pathname, function(err, stats) {
        if(!err && stats.isFile()) {
            fs.readFile('.' + pathname, function(err, html) {
                if (!err) {
                    response.writeHead(200, {
                        'Content-Type': 'text/html'
                    })                    
                    response.write(html);
                    response.end();
                }
            });
        } else {
            response.writeHead(404);
            response.write('Not Found');
            response.end();
        }
    })
});
server.listen(3000);

Content-Type怪怪的

目前所有的Content-Type都固定是text/html

mime套件

記得要先npm install mime

mime.getType(filename)

var mime = require('mime');
var url = require('url');
var fs = require('fs');
var http = require('http');
var server = http.createServer(function(request, response) {
    var pathname = url.parse(request.url).pathname;
    fs.stat('.' + pathname, function(err, stats) {
        if(!err && stats.isFile()) {
            fs.readFile('.' + pathname, function(err, html) {
                if (!err) {
                    response.writeHead(200, {
                        'Content-Type': mime.getType(pathname)
                    })                    
                    response.write(html);
                    response.end();
                }
            });
        } else {
            response.writeHead(404);
            response.write('Not Found');
            response.end();
        }
    })
});
server.listen(3000);

預設index.html起始頁

http://127.0.0.1:3000/

var mime = require('mime');
var url = require('url');
var fs = require('fs');
var http = require('http');
var server = http.createServer(function(request, response) {
    var pathname = url.parse(request.url).pathname;
    if (pathname.endsWith('/')) {
        pathname += '/index.html';
    }
    fs.stat('.' + pathname, function(err, stats) {
        if(!err && stats.isFile()) {
            fs.readFile('.' + pathname, function(err, html) {
                if (!err) {
                    response.writeHead(200, {
                        'Content-Type': mime.getType(pathname)
                    })                    
                    response.write(html);
                    response.end();
                }
            });
        } else {
            response.writeHead(404);
            response.write('Not Found');
            response.end();
        }
    })
});
server.listen(3000);

http://127.0.0.1/demo
http://127.0.0.1/demo/

這兩個網址不一樣嗎?

demo是資料夾還是檔案

一般來說檔案至少會有個.

沒有.那我就可能是資料夾

※當然有例外(我們暫不考慮)

// ...(略)
    } else {
        if (!pathname.includes('.')) {
            response.writeHead(302, {
                'Location': pathname + '/' + (url.parse(request.url).search || "")
            })
            response.end();
            return;
        }
    }
    fs.stat('.' + pathname, function(err, stats) {
// ...(略)

如果考慮例外怎麼辦?

// 註解剛剛前面寫的部分
// ...(略)
    fs.stat('.' + pathname, function(err, stats) {
        if(!err && stats.isDirectory()) {
            response.writeHead(302, {
                'Location': pathname + '/' + (url.parse(request.url).search || "")
            })
            response.end();
            return;
        }
        if(!err && stats.isFile()) {
// ...(略)

中文資料夾無法顯示

url傳送中文都會被編碼起來

ecsape()
encodeURI()
encodeURIComponent()

有何不同?

escape()

  • 編碼成 %XX 和 %uXXXX
  • ASCII字母、數字、@*/+不處理
  • 用在非URL上
  • 只能用unescape()解碼

encodeURI()
encodeURIComponent()

差別

  • 都是編碼成 %XX
  • encodeURI不處理ASCII字母、數字、符號
    ~!*()'@#$&=:/,;?+
  • 使用decodeURI解碼
  • encodeURIComponent不處理ASCII字母、數字、符號
    ~!*()'
  • 使用decodeURIComponent解碼
  • URL多半建議用encodeURIComponent處理
//...(略)
    }
    var relativePathname = decodeURIComponent(pathname);
    fs.stat('.' + relativePathname, function(err, stats) {
        if(!err && stats.isDirectory()) {
            response.writeHead(302, {
                'Location': pathname + '/' + (url.parse(request.url).search || "")
            })
            response.end();
            return;
        }
        if(!err && stats.isFile()) {
            fs.readFile('.' + relativePathname, function(err, html) {
                if (!err) {
                    response.writeHead(200, {
                        'Content-Type': mime.getType(relativePathname)
                    }) 
//...(略)

想把網站放在別的資料夾

透過啟動參數決定從哪個位置當作網站起點

// ...(略)
    }
    var relativePathname = decodeURIComponent((process.argv[2] || ".") + pathname);
    fs.stat(relativePathname, function(err, stats) {
        if(!err && stats.isDirectory()) {
            response.writeHead(302, {
                'Location': pathname + '/' + (url.parse(request.url).search || "")
            })
            response.end();
            return;
        }
        if(!err && stats.isFile()) {
            fs.readFile(relativePathname, function(err, html) {
                if (!err) {
// ...(略)

快取機制

節省網路頻寬

有聽過SHA1嗎?

一種不可逆的加密演算法

相同內容才能算出相同結果

雖然現在聽說sha1已經被破解了

但我們拿來做快取的檢查OK的

crypto模組

記得要先require喔

// ...(略)
if (!err) {
    var hash = crypto.createHash('sha1').update(html).digest('base64');
    if (request.headers['if-none-match'] == hash) {
        response.writeHead(304);
        response.end();
        return;
    }                    
    response.writeHead(200, {
        'Content-Type': mime.getType(pathname),
        "Etag": hash
    })    
// ...(略)

另外一種快取max-age

// ...(略)
    }
    var headers = {
        'Content-Type': mime.getType(pathname),
        "Etag": hash
    };
    if (headers["Content-Type"].startsWith('image/')) {
        delete headers.Etag;
        headers["Cache-Control"] = "max-age=3600";
    }
    response.writeHead(200, headers);
    response.write(html);
// ...(略)

是不是把Header和Status Code用好用滿

我們只花了49行

打造出一個可客製化的靜態網頁伺服器

var mime = require('mime');
var url = require('url');
var fs = require('fs');
var http = require('http');
var crypto = require('crypto');
var server = http.createServer(function(request, response) {
    var pathname = url.parse(request.url).pathname;
    if (pathname.endsWith('/')) {
        pathname += 'index.html';
    }
    var relativePathname = decodeURIComponent((process.argv[2] || ".") + pathname);
    fs.stat(relativePathname, function(err, stats) {
        if(!err && stats.isDirectory()) {
            response.writeHead(302, {
                'Location': pathname + '/' + (url.parse(request.url).search || "")
            })
            response.end();
            return;
        }
        if(!err && stats.isFile()) {
            fs.readFile(relativePathname, function(err, html) {
                if (!err) {
                    var hash = crypto.createHash('sha1').update(html).digest('base64');
                    if (request.headers['if-none-match'] == hash) {
                        response.writeHead(304);
                        response.end();
                        return;
                    }
                    var headers = {
                        'Content-Type': mime.getType(pathname),
                        "Etag": hash
                    };
                    if (headers["Content-Type"].startsWith('image/')) {
                        delete headers.Etag;
                        headers["Cache-Control"] = "max-age=3600";
                    }
                    response.writeHead(200, headers);
                    response.write(html);
                    response.end();
                }
            });
        } else {
            response.writeHead(404);
            response.write('Not Found');
            response.end();
        }
    })
});
server.listen(3000);

這有什麼了不起
Apache和IIS都有的功能啊

你不能客製化Apache或IIS
但Node.js可以

需要的功能才寫進去,不需要的功能就不用寫進去

依照需求客製化更輕量化

最重要的是
了解HTTP的基礎原理

當你在用Apache和IIS有想過會丟出什麼header和status code嗎?

下週預告

介紹什麼是Git版本控制

適合完全沒使用過版本控制的人

Q & A

從0開始打造網頁伺服器

By Yi-Tai Lin

從0開始打造網頁伺服器

  • 828