Quantcast
Channel: CNode:Node.js专业中文社区
Viewing all articles
Browse latest Browse all 14821

一个 Node.js 网站的开发总结

$
0
0

https://awhile.online是我最近使用 Node.js + koa + preact 技术栈进行开发的一个网站,网站整个源代码已经开源到 Github上了,以下内容是对开发过程的一点小结。

需求分析

网站的功能是可以由用户发布临时性的消息,消息的有效期可以自由定制,过期后自动删除,消息发布成功后自动生成一条链接用于查看该消息。

  • 注册/登录/登出
  • 发布消息
  • 显示消息
  • 消息必须加密存储
  • 发布消息时可以指定一个时间段
  • 过期的消息自动失效(删除)
  • 注册用户可以删除自己发布的消息

技术选型

网站最主要的功能是发布消息,但这个消息不是永久保存了,而且保存的时间不会太长,预计消息的长度也不会太长,因此使用 redis 来存储是比较合适。

由于对后端数据库这一块还不太熟悉,目前也就对 PostgreSQL 用得多点,因此在存储用户的注册信息这一块就选择了 PostgreSQL。

登录这一块打算使用 JWT 来做,因此无需保存用户状态到 PostgreSQL 数据库中,这样可以减少对数据库的操作。

后端选用 Node.js + koa + koa-router,另外由于现在 async/await 已经正式进入 ES2017 了,就打算使用 async/await 优化异步代码的结构。

前端准备做成单页应用,于是选用 preact + redux + redux-thunk。

前端的代码通过 webpack 打包,而后端的代码通过 rollup 打包(选用 rollup 打包是由于 Node.js 6+ 已经基本支持 ES2015 了,这里只需要将各个模块打包在一起,顺便把 async/await 转换成 Node.js 可以运行的代码就可以了,而这个工作非常合适 rollup 来做)。

核心库开发

在需求分析里,需要对消息进行加密处理,并且消息只在一段时间内有效,那可以将这段时间映射成一个值,然后使用这个值作为密码对消息进行加密,这样在这时间段之外的时间由于映射出来的值与该值不同,在对消息进行解密时会解密失败。这样就可以实现对消息加密、过期消息自动失效这两个需求了。

加密解密消息和将时间段映射成一个值的功能已经独立出来放到 NPM上了。

后端开发

后端数据库由于使用了 PostgreSQL,因此在 Node.js 中使用 pg包来连接它。 另外使用 redis包来连接 redis。

在使用 pg包时,需要注意的一点是不要直接使用 pg.Client 来创建一个 Client 实例,而是通过 pg.Pool 创建一个连接池,通过连接池自动管理 Client 实例,这样一来可以避免手动管理 Client 实例的麻烦,还可以使 Client 实例重复利用,提高处理速度,最重要是的 pg.Pool 的实例支持 Promise,这样可以愉快地使用 async/await。

而使用 redis包就没那么好运了,它日前只支持 callback 方式,要想支持 Promise,需要使用一些 Promise 库来进行 wrap 才行,还好之前我已经开发了一个类似的库(promise-adapter),只需要简单几段代码封装下就可以支持 Promise。

在使用 jwt 作为用户的登录状态管理时,刚开始的时候使用从环境变量 JWT_SECRET 传进来的值作为 jwt 的 secret,当使用登录时,如果验证用户登录成功后,就使用 jwt 生成一个 token 返回客户端,以后涉及到需要用户登录的操作都使用这个 token 作为用户登录凭证,在验证这个 token 的时候,也只是验证 token 是否合法。由于服务器端不保存任何的登录状态,当 secret 漏泄时,攻击者就可以通过这个 secret 来生成任意用户的合法的 token,这无疑是非常危险的。

后来就放弃了使用 JWT_SECRET 作为 secret,而改用随机生成 secret。当用户登录成功后,随机生成一个 key 和 secret,将 key 设置到 jwt payload 的 jti 中,然后使用 secret 为这个 payload 签名生成 token 返回给客户端,同时将 key 和 secret 保存到 redis 中。以后验证 token 的时候,根据 token 中的 jti 去 redis 里取出 secret,再来验证 token 是否合法就可以。

这里同时生成 key 和 secret 可以使用户在多个客户端同时登录,如果只想同一时间只能在一个客户端登录,可以使用用户名作为 key 来保存 secret 到 redis 中。

前端开发

前端一开始的时候,是不准备用 preact 的,因为觉得网站功能不多,并且非常简单。但后来深入分析了下,发现有些地方用纯原生的 js 写起来有些麻烦,而用 react 写起来就非常简单的。为了加快开发速度,只能选择 react 了,但 react 打包后的体积还是偏大,对于一个非常简单的网站使用,觉得有点不合适。这时,另一个 react 替代者 preact 进行了的我选择名单中,根据 preact 的 README 介绍,仅有 3kb 大小,虽然对此表示存疑,但这也说明了它确实是一个轻量级的 react 替代者。

通过简单的了解,发现 API 与 react 差异不太大,至少还可以接受,但在测试的时候有个坑。由于测试工具使用 jest,而 jest 对 preact 的支持不太好,无奈只有在测试的时间使用 react,而在正式环境中使用 preact,写 react 组件的时候尽量选择两者兼容的功能来使用。

有同学可能有疑问,代码里引入的是 preact包,如何在测试中使用 react包呢?这就多亏了 babel 的帮助,我只需要写一个 babel plugin,在测试的环境里将 preact 改成 react 就可以了,对这个 plugin 感兴趣的同学可以到 tools/babel-plugin-preact-to-react.js查看。

部署

由于 VPS 上使用 centos7,已经使用 systemd 作为系统和服务的管理器了,因此打算直接使用 systemd 来管理 Node.js 应用。

既然使用 systemd 来管理,那就需要编写 systemd service 文件了,但在编写之前,需要先把 postgresql 和 redis 安装到服务器上。

postgresql 的安装由于之前写过的博文里介绍了,这里就不展开了,这里说下 redis 的安装。

由于 centos7 上直接可用的 redis 包的版本已经低,这里直接在 redis 下载原代码来编译。编译的过程比较简单,将下载好的源码压缩包解压到指定的目录里,然后进入该目录执行 make就可以编译了。如果在编译过程中报错,可能是编译所需要的工具未安装,这里比较省事的方法,可以通过 yum groupinstall "development tools"将一些编译所需要的开发工具装上。

编译完成后,可以运行 make install将 redis 安装到 /usr/local/bin下面,上面说过了,使用 systemd 来管理服务,而 redis 默认没有 systemd 的服务文件,因此需要为 redis 编写一个服务文件,这里我使用 Arch 里 redis 的服务文件修改下直接用:

[Unit]
Description=Advanced key-value store
After=network.target

[Service]
Type=simple
User=redis
Group=redis
ExecStart=/usr/bin/local/redis-server /etc/redis.conf
ExecStop=/usr/bin/local/redis-cli shutdown
CapabilityBoundingSet=
PrivateTmp=true
PrivateDevices=true
ProtectSystem=full
ProtectHome=true
NoNewPrivileges=true
RuntimeDirectory=redis
RuntimeDirectoryMode=755
LimitNOFILE=10032

[Install]
WantedBy=multi-user.target

将上面的内容保存到 /etc/systemd/system/redis.service中。

从上面的内容可以知道,还需要为 redis 准备一个用户(及用户组),以及将配置文件 redis.conf 复制到 /etc/目录下。

为 redis 创建用户,可以使用以下命令:

useradd -d /var/lib/redis -m -s /bin/false -U redis

然后编辑 redis 源码目录下 redis.conf配置文件,将 dir的值修改成 /var/lib/redis

dir /var/lib/redis

保存好后,将其复制到 /etc/目录下。

到此,redis 的配置已经算完成了,然后可以使用 systemctl enable redis.service开启开机自动自动,使用 systemctl start redis.service启动 redis,同时可以使用 systemctl status redis.service查看启动是否正常。

前面 postgresql 和 redis 已经配置好了,是时候为 awhile.online 配置 systemd 服务文件了。awhile.online 的 systemd 服务文件与 redis 的类似:

[Unit]
Description=Awhile.online - a node.js App
Requires=rh-postgresql95-postgresql.service
Requires=redis.service
After=rh-postgresql95-postgresql.service redis.service

[Service]
Type=simple
User=awhile
Group=awhile
ExecStart=
ExecStart=/usr/bin/node /srv/awhile.online/www/index.js
ExecReload=/usr/bin/kill -HUP $MAINPID
Restart=on-failure
WorkingDirectory=/srv/awhile.online/www
EnvironmentFile=/home/awhile/env.txt

[Install]
WantedBy=multi-user.target

将上面的内容保存到 /etc/systemd/system/awhile.online.service中。

这里需要为 awhile.online 也单独创建一个用户 awhile来运行:

useradd -m -U awhile

创建成功后,切换到 awhile用户(su - awhile,如果提示需要密码,先使用 passwd awhile设置密码),将以下内容保存到 ~/env.txt下:

NODE_ENV = production
NODE_ASSETS_PATH = public

HOST = localhost
PORT = 6380

PGHOST = localhost
PGPORT = 5432
PGUSER = awhile
PGDATABASE = awhile_online

REDIS_URL = redis://localhost:6379

由于将 awhile.online 项目放到 /srv/awhile.online/www目录下,因此需要创建该目录:

mkdir -p /srv/awhile.online
chown awhile:awhile /srv/awhile.online
mkdir -p /srv/awhile.online/www

目录创建好后,现在就需要通过 rsync + ssh 将本地的 awhile.online 项目上传到服务器,但上传之前最好为 服务器上的 awhile用户创建一个 ssh 非对称密钥用于 ssh 登录。

在本地运行 ssh-keygen生成密钥:

ssh-keygen -t ed25519 -f ~/.ssh/awhile.online

然后将公钥内容复制到服务器的 awhile用户的 .ssh/authorized_keys文件内:

ssh-copy-id -i ~/.ssh/awhile.online.pub awhile@<你的服务器地址>

现在可以使用 rsync 来上传 awhile.online 项目了,切换到本地的 awhile.online 项目目录下,先对项目进行 build:

npm run build

然后使用 rsync 同步到服务器的 /srv/awhile.online/www目录下:

rsync -crlptHP --exclude "/node_modules/" --delete dist/ awhile@<REMOTE_HOST>:/srv/awhile.online/www

现在再次登录服务器来开启 awhile.online.service,登录到服务器后,由于 systemctl命令需要特权用户才可以执行开启、启动等动作,而使用 su 切换到 root 用户不仅不安全,而且每次更新都需要登录服务器显得很麻烦,因此这里为 awhile提供一种比较安全便捷的方式管理 awhile.online.service服务。

首先使用命令 visudo -f /etc/sudoers.d/awhile_online来创建编辑新的文件 /etc/sudoers.d/awhile_online

将以下内容复制到该文件中保存:

awhile ALL=(root) NOPASSWD: /usr/bin/systemctl status awhile.online.service, /bin/systemctl start awhile.online.service, /usr/bin/systemctl stop awhile.online.service, /usr/bin/systemctl reload awhile.online.service, /usr/bin/systemctl restart awhile.online.service, /usr/bin/systemctl reload-or-restart awhile.online.service

上面内容的意思是用户名为 awhile,授权使用 root 用户,无需密码地运行后面的 /usr/bin/systemctl status awhile.online.service 等命令。

这样就既不用切换到 root 用户,也无需为 sudo 输入密码,就可以管理了。

保存成功后,可以使用 sudo -llU awhile命令查看是否生效了,运行命令后会显示以下信息:

Matching Defaults entries for awhile on this host:
    requiretty, !visiblepw, always_set_home, env_reset, env_keep="COLORS
    DISPLAY HOSTNAME HISTSIZE INPUTRC KDEDIR LS_COLORS", env_keep+="MAIL PS1
    PS2 QTDIR USERNAME LANG LC_ADDRESS LC_CTYPE", env_keep+="LC_COLLATE
    LC_IDENTIFICATION LC_MEASUREMENT LC_MESSAGES", env_keep+="LC_MONETARY
    LC_NAME LC_NUMERIC LC_PAPER LC_TELEPHONE", env_keep+="LC_TIME LC_ALL
    LANGUAGE LINGUAS _XKB_CHARSET XAUTHORITY",
    secure_path=/sbin\:/bin\:/usr/sbin\:/usr/bin

User awhile may run the following commands on this host:

Sudoers entry:
    RunAsUsers: root
    Commands:
        NOPASSWD: /usr/bin/systemctl status awhile.online.service
    RunAsUsers: root
    Commands:
        /usr/bin/systemctl start awhile.online.service
    RunAsUsers: root
    Commands:
        /usr/bin/systemctl stop awhile.online.service
    RunAsUsers: root
    Commands:
        /usr/bin/systemctl reload awhile.online.service
    RunAsUsers: root
    Commands:
        /usr/bin/systemctl restart awhile.online.service
    RunAsUsers: root
    Commands:
        /usr/bin/systemctl reload-or-restart awhile.online.service

通过 Sudoers entry: 列出的命令,已经可以看到已经生效了。可能有同学会有疑问,为什么只有第一个命令有 NOPASSWD,其他的命令没有,是不是需要每个命令前面都需要添加 NOPASSWD?这里就不需要了,因为 NOPASSWD是可以继承的,也就是说只需要第一个命令前面加上了,后面的也继承下来,除非后面命令的前面加上 PASSWD覆盖了 NOPASSWD

现在已经可以在 awhile 用户下,使用 sudo systemctl start awhile.online.service等命令来操作 awhile.online.service 了,但是还不能在本地使用 ssh 直接运行上述命令。重新看下上面的 sudo -llU awhile显示的信息里,可以看到 Matching Default下面的第一个是 requiretty,如果你现在通过 ssh 运行,比如:

ssh <REMOTE_HOST> sudo systemctl restart awhile.online.service

就会提示

sudo: sorry, you must have a tty to run sudo

这是由于 ssh 默认没有开 tty,而上面的 requiretty是需要 tty 来运行 sudo 命令的,因此会报错。

解决的方法有两个,一是给 ssh 添加 -t参数,该参数可以在连接时开启 tty。

另一个方法是编辑 /etc/sudoers.d/awhile_online文件(同样使用上面的 visudo -f /etc/sudoers.d/awhile_online命令来编辑),添加以下内容:

Defaults:awhile !requiretty

来关闭 awhile 用户的 requiretty 选项。

保存后,重新运行 sudo -llU awhile会后台 Mathing Default 下面一行的最后那里添加了 !requiretty

至此,整个部署工作算完成了,以后可以通过 rsync 同步更新版本,然后通过 ssh awhile@<REMOTE_HOST> sudo reload awhile.online.servicessh awhile@<REMOTE_HOST> sudo restart awhile.online.service来应用更新了(这里如果仅更新 awhile.online 的浏览器端资源,可以使用 reload,如果需要更新 awhile.online 的 Node.js 端的,需要使用 restart)。


Viewing all articles
Browse latest Browse all 14821

Trending Articles