一、开发前的准备
- cross-env 设置环境变量
- nodemon 热更新(修改代码之后立即重启项目)
使用:两个插件均使用npm安装即可,安装好之后在package.json的脚本中插入以下两个脚本
"scripts": {
"dev": "cross-env NODE_ENV=dev nodemon ./bin/www.js",
"prd": "cross-env NODE_ENV=production nodemon ./bin/www.js"
},
用dev脚本举例,该脚本使用cross-env修改NODE_ENV变量为dev,并且使用nodemon热更新启动,入口文件为./bin/www.js。
注意:cross-env修改的是node全局变量Process.env,NODE_ENV则是自己定义的,如果Process.env.NODE_ENV存在,则修改,不存在则创建。之后,整个node项目都可以使用process.env.NODE_ENV来判断当前的环境。
贴一下项目文件目录
二、最简单的服务
最简单的服务就是下面这几行代码。
const http = require('http')
const server = http.createServer((req, res) => {
res.end('hello word')
})
server.listen(8000)
执行上面的代码,访问localhost:8000即可看到hello word。
我们的项目也将由这个最简单的服务展开。
三、开发路由
1. 服务模块、路由模块分离
给原始代码加入路由判断,类似于
const server = http.createServer((req, res) => {
if(req.method === 'GET' && req.path === '/api/blog/list'){
return {
data: '获取到博客列表',
code: 200
}
}
})
模块化提取一下,然后加入response格式设置,404处理。
./bin/www.js
const http = require('http')
const PORT = 8000
const serverHandle = require('../app')
const server = http.createServer(serverHandle)
server.listen(PORT)
./app.js
const queryString = require('querystring')
const handleUserRouter = require('./src/router/user')
const severHandle = (req, res) => {
// 设置返回格式JSON
res.setHeader('Content-type','application/json')
const userData = handleUserRouter(req, res)
if(userData){
userData.then(u => {
res.end(JSON.stringify(u))
})
return
}
/** 还可以插入其他路由,从上到下,命中一个就停止 **/
// 未命中路由
res.writeHead(404, {"Content-type": "text/plain"})
res.write("404 Not Found ~\n")
res.end()
}
module.exports = severHandle
./src/router/user.js
const handleUserRouter = (req, res) => {
if(req.method === 'POST' && req.path === '/api/user/login'){
return '登录成功'
}
}
module.exports = handleUserRouter
2. 处理request中query和post数据
./app.js
const queryString = require('querystring')
const handleUserRouter = require('./src/router/user')
const getPostData = (req) => {
return new Promise((resolve, reject) => {
if(req.method !== 'POST'){
resolve({})
return
}
if(req.headers['content-type'] !== 'application/json'){
resolve({})
return
}
let postData = ''
req.on('data', chunk => postData += chunk.toString())
req.on('end', () => {
if(!postData){
resolve({})
return
}
resolve(
JSON.parse(postData)
)
})
})
}
const severHandle = (req, res) => {
// 设置返回格式JSON
res.setHeader('Content-type','application/json')
const url = req.url
req.path = url.split('?')[0]
// 解析query
req.query = queryString.parse(url.split('?')[1])
getPostData(req).then(postData => {
req.body = postData
const userData = handleUserRouter(req, res)
if(userData){
userData.then(u => {
res.end(JSON.stringify(u))
})
return
}
// 未命中路由
res.writeHead(404, {"Content-type": "text/plain"})
res.write("404 Not Found ~\n")
res.end()
})
}
module.exports = severHandle
这样可以通过req.body访问post的数据,通过req.query访问到query的数据
四、数据库mysql
1. mysql配置文件
npm安装mysql依赖,并添加一个mysql的配置文件,输出链接mysql需要的用户名、密码、数据库端口、名称等必要信息。
./src/config/db.js
const env = process.env.NODE_ENV
let MYSQL_CONFIG = {}
if(env === 'dev'){
// mysql
MYSQL_CONFIG = {
host: 'localhost',
user: 'root',
password: '123456',
port: '3306',
database: 'myblog'
}
}
module.exports = {
MYSQL_CONFIG,
}
2. sql执行函数
还需要一个执行sql语句的函数,我们也单独抽出来。
./src/db/mysql.js
const mysql = require('mysql')
const { MYSQL_CONFIG } = require('../config/db') // 来自上面的配置文件
// 创建一个mysql实例,因为这是一个单例,所以不会用到退出数据库
const con = mysql.createConnection(MYSQL_CONFIG)
// 连接数据库
con.connect()
// con.query是一个异步,且是回调的形式,所以我们将其封装成promise的形式,更方便使用
const exec = (sql) => {
return new Promise((resolve, reject) => {
con.query(sql, (err, result) => {
if(err){
reject(err)
return
}
resolve(result)
})
})
}
module.exports = {
exec,
escape: mysql.escape
}
3. controller调用sql执行语句
./src/controller/user.js
const { exec, escape } = require('../db/mysql')
const login = (username, password) => {
// 拼接sql语句
const sql = `select username, realName from users where username=${username} and password=${password}`
// 调用我们封装的sql语句执行函数,依旧返回一个promise。
return exec(sql).then(rows => {
// sql查出来是“行”数组形式
return (rows[0] || {})
})
}
module.exports = {
login
}
然后我们就可以在router里面去调用login函数判断用户名、密码是否正确。
./src/router/user.js
if(req.method === 'POST' && req.path === '/api/user/login'){
const {username, password} = req.body
const result = login(username, password)
return result.then(loginData => {
if(loginData){
return "登录成功"
}else{
return "登录失败"
}
}).catch(err => {
return "登录失败"
})
}
五、cookie和session
1. cookie
用户登录之后,访问其他页面也需要能验证对方是已处于登录状态,有些页面还需要读取当前用户的信息。此时就需要用到cookie。
cookie存储在浏览器(客户端),大小4kb,有跨域限制。每次发送请求都会自动把cookie带给服务端。我们可以在用户登录之后,把用户信息写到cookie,之后此域名下所有页面都能访问到这个用户信息。
可以在控制台看到详细的cookie信息。
在登录成功之后,在cookie中写入用户信息。
./src/router/user.js
if(req.method === 'POST' && req.path === '/api/user/login'){
const {username, password} = req.body
const result = login(username, password)
return result.then(loginData => {
if(loginData){
res.setHeader('Set-Cookie', `username=${username}; path=/; httpOnly; expires=${1000000000}`)
return "登录成功"
}else{
return "登录失败"
}
}).catch(err => {
return "登录失败"
})
}
- path=/ 表示所有路由都可以访问到这个cookie。
- httpOnly 正常情况,客户端可以通过document.cookie或其他方法去修改cookie,加上httpOnly之后,客户端无法修改。
- expires 是cookie的过期时间
在处理路由之前,我们拿到cookie,进行解析,然后在其他页面就可以通过cookie拿到用户名,以此来判断用户是否登陆了。
./app.js
const severHandle = (req, res) => {
res.setHeader('Content-type','application/json')
const url = req.url
req.path = url.split('?')[0]
// 解析query
req.query = queryString.parse(url.split('?')[1])
// 解析cookie
req.cookie = {}
const cookieStr = req.headers.cookie || ''
cookieStr.split(';').forEach(item => {
if(!item){
return
}
const arr = item.split('=')
const key = arr[0].trim()
const val = arr[1].trim()
req.cookie[key] = val
})
getPostData(req).then(postData => {
/** 省略 **/
})
}
2. session
显然,用户信息直接这样存在客户端,是很不安全的。于是,出现了session。
session不同于cookie,cookie是http中自带的,而session只是约定俗成的一个变量,存储在服务端。
我们可以把用户信息存在session中,然后cookie中存放对应的一个key,可以去session中查到对应的信息。
例如:
session = {
'jskqus6hdk9k': {
username: 'zhangsan',
email: 'zhangsan@gmail.com'
}
}
cookie = {
sessionId: 'jskqus6hdk9k'
}
用户登录,服务端生成随机且唯一的key,用户信息作为value,存储到session,session存在服务端(放到内存、redis 或 mysql中)。同时把这个随机key设置到cookie中。
用户访问其他页面而发送请求时,会自动把cookie发送给服务端。服务端可以看是否有这个随机key,来判断用户是否登陆,切能拿到该用户的信息。
./app.js
// 全局session对象
const SESSION_DATA = {}
const severHandle = (req, res) => {
/** 省略 **/
// 解析session
let neeSetCookie = false
let userId = req.cookie.userId
if(userId){
console.log('SESSION_DATA',SESSION_DATA)
if(!SESSION_DATA[userId]){
SESSION_DATA[userId] = {}
}
}else{
neeSetCookie = true
userId = `${Date.now()}_ ${Math.random()}` // 作为sessionId,保证不重复就行
SESSION_DATA[userId] = {}
}
req.session = SESSION_DATA[userId]
getPostData(req).then(postData => {
req.body = postData
// 处理路由
const userData = handleUserRouter(req, res)
if(userData){
userData.then(u => {
if(needSetCookie){
res.setHeader('Set-Cookie', `userId=${userId}; path=/; httpOnly; expires=${getCookieExpires()}`)
}
res.end(JSON.stringify(u))
})
return
}
})
}
// 获取 cookie 的过期时间
const getCookieExpires = () => {
const d = new Date()
d.setTime(d.getTime() + (24 * 60 * 60 * 1000))
console.log(d.toGMTString())
return d.toGMTString()
}
./src/router/user.js
if(method === 'POST' && req.path === '/api/user/login'){
const {username, password} = req.body
const result = login(username, password)
return result.then(loginData => {
if(loginData.username){
// 登录成功,设置session
req.session.username = loginData.username
req.session.realName = loginData.realName
/** 省略 **/
})
}
六、session存入redis
上述方案中,session是存储在内存中的,每次项目重启,都会导致SESSION_DATA这个变量丢失。显然session放到内存中是不行的。而且session的访问频率很高,而且大小通常较小,放到redis是一个很不错的选择。
1. redis配置文件
首先npm安装redis依赖。并在配置文件中加入redis的相关配置。
./src/config/db.js
const env = process.env.NODE_ENV
let MYSQL_CONFIG = {}
let REDIS_CONFIG = {}
if(env === 'dev'){
// mysql
MYSQL_CONFIG = {
}
// redis
REDIS_CONFIG = {
port: 6379,
host: '127.0.0.1'
}
}
/** 可以加入其他环境的配置 **/
module.exports = {
MYSQL_CONFIG,
REDIS_CONFIG
}
2. 封装redis的执行函数
./src/db/redis.js
const redis = require('redis')
const { REDIS_CONFIG } = require('../config/db')
// 创建客户端
const redisClient = redis.createClient(REDIS_CONFIG.port, REDIS_CONFIG.host)
redisClient.on('error', err => console.log(err))
const set = (key,val) => {
if(typeof val === 'object'){
val = JSON.stringify(val)
}
redisClient.set(key, val, redis.print)
}
const get = (key) => {
return new Promise((resolve, reject) => {
redisClient.get(key, (err, val) => {
if(err){
console.log(err)
return
}
if(val === null){
resolve(val)
return
}
try {
resolve(JSON.parse(val)) // 是JSON格式就转化一下
} catch(ex) {
resolve(val) // 抛错证明不是JSON
}
})
})
}
module.exports = {
set,
get
}
3. 把session存入redis
我们需要在解析路由之前需要从redis里拿出session,并解析
/** 省略 **/
// 解析 session (使用 redis)
let needSetCookie = false
let userId = req.cookie.userId
if (!userId) {
needSetCookie = true
userId = `${Date.now()}_${Math.random()}`
// 初始化 redis 中的 session 值
set(userId, {})
}
// 获取 session
req.sessionId = userId
get(req.sessionId).then(sessionData => {
if (sessionData == null) {
// 初始化 redis 中的 session 值
set(req.sessionId, {})
// 设置 session
req.session = {}
} else {
// 设置 session
req.session = sessionData
}
// 处理 post data
return getPostData(req)
})
.then(postData => {
req.body = postData
// 处理路由
/** 省略 **/
其次在用户登录成功之后需要设置最新的session
./src/router/user.js
if(method === 'GET' && req.path === '/api/user/login'){
const {username, password} = req.query
const result = login(username, password)
return result.then(loginData => {
if(loginData.username){
// 设置session
req.session.username = loginData.username
req.session.realName = loginData.realName
// 同步到 redis
set(req.sessionId, req.session)
return new SuccessModel(loginData)
}else{
return new ErrorModel('登录失败')
}
}).catch(err => {
return new ErrorModel('登录失败')
})
}
七、nginx配置
1. 给前端html启动一个服务
如果前端页面没有用npm管理,只是简单的一个html文件,为了做到前后端分离,我们可以给前端静态页面单独起一个服务。需要用到依赖http-server,全局安装依赖。
npm install http-server -g
然后在html文件的目录下执行
http-server -p 8001
然后就可以访问localhost:8001来访问我们的静态页面。如:localhost:8001/index.html。
此时,前后端服务不在同一个端口,会有跨域问题,可以采用cors等方法去解决跨域问题,也可以用到nginx来分别代理两个端口到同一个端口上,就不会有跨域问题。
2.nginx反向代理
将根域名/…下的的访问都指向前端静态资源,将/api/…下的访问都指向后端端口。
3. nginx使用
下载nginx,windows去官网,mac直接运行
brew install nginx
打开nginx配置文件,window采用记事本,mac使用vi或者vim。
windows: C:\nginx\conf\nginx.conf
Mac: /usr/local/etc/nginx/nginx.conf
sudo vi /usr/local/etc/nginx/nginx.conf
修改server中listen为8080,注释掉旧的location,加入两个新的location
#全局块
events { #events块
...
}
http #http块
{
... #http全局块
server #server块
{
listen 8080;
server_name localhost;
#charset koi8-r;
#access_log log/host.accsess.log main;
# 注释掉旧的
#location / {
# root
# html;
# index index.html index.htm;
#}
# 加入两个新的
location / {
proxy_pass http://localhost:8001;
}
location /api/ {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
}
location [PATTERN]
{
...
}
}
server
{
...
}
#http全局块
}
配置完成之后
nginx -t // 测试配置文件格式是否正确
nginx // 启动nginx
然后就可以通过localhost:8080访问前端页面,并成功调取后端接口
八、写入日志
1. 先看另一篇
2. 写日志函数
有了上面读写文件的基础理解,可以很轻松地实现一个写入日志的函数。
增加一个文件
./src/utils/log.js
const fs = require('fs')
const path = require('path')
// 写日志
const writeLog = (writeStream, log) => {
writeStream.write(log + '\n')
}
// 生成write stream
const createWriteStream = (fileName) => {
// 日志的输出目录
const fullFileName = path.join(__dirname, '../../', 'logs', fileName)
const writeStream = fs.createWriteStream(fullFileName, {
flags: 'a' // 追加写入, 覆盖用 ‘w’
})
return writeStream
}
// 写访问日志
const accessWriteStream = createWriteStream('access.log')
const access = log => {
writeLog(accessWriteStream, log)
}
// 写xx日志
// ......
module.exports = {
access
}
3. 服务入口处增加访问日志记录
./app.js
const severHandle = (req, res) => {
console.log('进入serverHandle',SESSION_DATA)
// 记录 access log
access(`${req.method} -- ${req.url} -- ${req.headers['user-agent']} -- ${Date.now()}`)
// 设置返回格式JSON
res.setHeader('Content-type','application/json')
/** 省略 **/
}
4. 分割日志文件
通常我们的日志文件需要按日期进行分割,需要用到一个简单的shell脚本
#!/bin/sh
cd /Users/Documents/blog-1/logs # 这里指向项目的logs文件夹
# 拷贝access.log文件到新的文件,新的文件命名是2021-10-27-19.access这种格式,具体可以搜shell脚本获取日期的方法
cp access.log $(date +%Y-%m-%d-%H).access.log
echo "" > access.log # 将空内容写入access.log,即清空access.log文件
还需要一个定时器,每天定时执行这个脚本。实现方式就是linux的crontab命令。(服务端基本都是部署在linux系统上,所以不考虑window的情况)
在terminal中执行
crontab -e
在打开的编辑器中加入一个定时任务。(具体含义可以查看linux crontab命令)
* 0 * * * sh /Users/Documents/blog-1/src/utils/copy.sh # 指向项目中的copy.sh
然后服务器会在每天零点拷贝日志。
5. 分析日志
我们可以写一些函数来读取并分析日志里的内容,举例子,分析访问的客户端浏览器情况,统计chrome浏览器的占比。 需要用到readline函数
增加一个文件
./src/utils/readlog.js
const fs = require('fs')
const path = require('path')
const readline = require('readline')
// 文件名
const fileName = path.join(__dirname, '../../', 'logs', 'access.log')
// 创建read stream
const readStream = fs.createReadStream(fileName)
// 创建readline对象
const rl = readline.createInterface({
input: readStream
})
let chromeNum = 0
let sum = 0
// 计算chrome浏览器访问的占比
rl.on('line', (lineData) => {
if(!lineData){
return
}
sum ++
const arr = lineData.split(' -- ')
if(arr[2] && arr[2].indexOf('Chrome') > 0){
chromeNum ++
}
})
// 监听读取完成
rl.on('close', () => {
console.log('chrome 占比:' + chromeNum / sum)
})
九、安全处理
1. sql注入
使用mysql依赖自带的escape函数
./src/db/mysql.js
// 直接输出mysq的escape函数
module.exports = {
exec,
escape: mysql.escape
}
以登陆函数举例
./src/controller/user.js
const { exec, escape } = require('../db/mysql')
const login = (username, password) => {
// 用escape函数把传入的参数包一下,这样子里面任何sql的特殊符号都会被转义
username = escape(username)
password = escape(password)
const sql = `select username, realName from users where username=${username} and password=${password}`
return exec(sql).then(rows => {
return (rows[0] || {})
})
}
2. xss攻击
xss主要是插入恶意的
const { exec, escape } = require('../db/mysql')
const xss = require('xss')
const login = (username, password) => {
username = escape(username)
password = escape(password)
// 用xss函数包裹一下传入的变量就好了,但是要注意,包裹之后的变量外面需要加一层引号,可以对比一下password和username两个地方
const sql = `select username, realName from users where username='${xss(username)} 'and password=${password}`
return exec(sql).then(rows => {
return (rows[0] || {})
})
}
3. 数据库敏感信息加密
用户名、密码不能明文存在数据库,因为数据库一旦泄漏,就会很危险。因此需要加密处理,我们采用简单的加密举例,使用node自带的crypto函数,进行简单的md5加密。
新建一个文件
./src/utils/cryp.js
const crypto = require('crypto') // nodejs自身提供的加密的库
// 自己随便定义一个密钥
const SECRET_KEY = 'WjsakIG_876'
const md5 = (content) => {
let md5 = crypto.createHash('md5')
return md5.update(content).digest('hex') // 把输出变成16进制
}
const genPassword = (password) => {
// 可以自己随便定义一个把密钥包含进去的密码规则,然后再由md5进行加密
const str = `password=${password}&key=${SECRET_KEY}`
return md5(str)
}
console.log(genPassword('123'))
在用户注册的时候我们就存入加密后的密码,登录之后也是加密之后跟数据库进行比较,这样服务端全程都不会知道用户真实的密码。
十、完结
别看了,就是完事儿了。