分类
js

用 express.js 实现流式输出 HTTP 响应

0. 前言

我们先来总结一下客户端与服务器端的数据交互方式:

  1. ajax,即 XMLHttpRequest,最常见的数据交互方式,适用于较轻量级的一次性请求,比如用户登录、CRUD 等。
  2. SSE,服务器端事件,适用于有大量服务器端推送、且不需要从客户端发送数据的需求,比如盯股票行情
  3. WebSocket,适用于上下行数据都很多、都很频繁的情况,比如在线聊天、网络游戏等

单从技术角度来看,大概是这几个。根据需求变化,我也可以组合出更多方案。比如,类似 Showman 生成视频这种需求,耗时很长,对服务器资源占用很高,就比较适合:

  1. 发起请求,产生一条任务记录,进入队列
  2. 服务器上运行独立的进程,从队列中取任务出来执行,并记录日志
  3. 客户端不断检查任务进度

1. 需求

今天的需求,介于轻量一次性,与需要服务器长时间执行之间,即 FastTest 中的发布功能。这里,我们有以下需求:

  1. 管理员点击 Publish 按钮,开始发布静态网站
  2. 服务器需要先保存数据,然后调用 webpack 发布所有语言版本
  3. 只有少数管理员会使用此功能,服务器压力不大
  4. 管理员希望能了解发布进度,及每一步的状态

所以,提交任务后等待服务器慢慢跑就不合适;等待请求完成一次性看到所有结果也不合适;最合适的,就是基于长链接,不断获取返回信息,并在前端实时看到进度。也即是:流式输出 HTTP 响应。

2. 实现

2.1 后端

后端实现我选择 node.js + express.js,在后端领域,这两个我比较熟悉。express.js 是在 node.js 网络模块上进行封装得来,使用起来很简单,也支持原生 node.js 方法。

首先我们创建一个服务器:

const express = require('express');
const app = express();
const port = 3100;

app.listen(port, () => {
  console.log('FastTest Admin API at: ', port);
});

然后,我们在需要流式返回响应的接口里设置相应头 Content-type: application/octet-stream。接下来,只要我们不断向输出流写入内容就可以了。哦,对了,结束的时候,我们还要关闭输出流。

app.post('/data', async(req, res, next) => {
  res.setHeader('Content-type', 'application/octet-stream');
  
  // 第一次返回
  res.write('Local data saved. Start to build dist。files.\n');

  // 数次返回
  for (const item of items) {
    await doSomething(item);
    res.write(`${item} done successfully.\n`);
  }

  // 最后,全部完成
  res.write('All done.');
  // 关闭输出流
  res.end();
});

2.2 axios

axios 是很流行的 ajax 库,它进行了 Promise 封装,用起来很方便。这里我们要用 onDownloadProgress(即 axios 对 XMLHttpRequest.progress 的封装)获取下载进度,它会在每次服务器返回响应字符串的时候更新,我们只需要截取上次响应之后,这次响应新增的内容,即可。

function publish(data, onDownloadProgress) {
  return axios.post('/data', data, {
    onDownloadProgress,
  });
}

async function doPublish() {
  if (isLoading.value) {
    return;
  }

  isPublishing.value = true;
  message.value = status.value = null;

  try {
    const { cases, lang } = store.state;
    let offset = 0;
    await publish({ cases, lang }, ({ target: xhr }) => {
      // responseText 包含了从一开始到此刻的全部响应内容,所以我们需要从上次结束的位置截取,获得新增的内容
      const { responseText } = xhr;
      const chunk = responseText.substring(offset);
      // 记录这一次的结束位置
      offset = responseText.length;
      currentStatus.value = chunk;
    });
    status.value = true;
    currentStatus.value = '';
    message.value = 'Published successfully.';
  } catch (e) {
    message.value = 'Failed to publish. ' + e.message;
  }
  isPublishing.value = false;
}

2.3 Vue3

相对来说,Vue3 的部分最容易。这里我用了 animate.css 的 flash 效果,让信息更显眼。除此之外,就是简单的赋值。

<template lang="pug">
.alert.alert-info.mb-0.me-2.py-1.px-3.animated.flash.infinite.slower(
  v-if="currentStatus",
) {{currentStatus}}
</template>

3. 效果演示

流式输出效果演示

4. 部署

一般来说,我们很少会直接用 node.js 当服务器,多半会启动 node.js 服务,然后用 nginx 反向代理给用户访问。这里需要注意,nginx 默认会将响应内容存入缓冲区,然后批量返回给客户端。这会导致流式输出无效,变成常规的执行完毕后一次性输出。

所以我们必须修改 nginx 的配置:

# 仅展示有关配置
location /data {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $http_host;
        proxy_pass http://localhost:3100;
        # 关闭代理缓冲区,所有响应实时输出
        proxy_buffering off;
}

5. 总结 & 扩展阅读

前些天我帮朋友做了个小项目,叫 FastTest。项目很简单,不过我主动扩展了技术选型,使用了非常多的技术,都是浅显入门的程度,非常适合用来做基础入门学习。本文也是从这个项目中提炼出来的。建议大家有空的时候看看,拉到本地,跑起来试试看效果。

文中的几段代码,分别位于:

项目的详细介绍请见:超全面全栈入门项目:fastest

扩展阅读

分类
服务器端

nginx 笔记

基础配置

# daemon on;
# worker_processes 1;
error_log logs/travis.error.log error;
pid logs/travis.nginx.pid;

events {
    accept_mutex off;
}

http {

    server {
        listen 9000;

        include mime.types;

        location / {
            rewrite ^ /static/edge/index.html last;
        }

        location /admin-api/ {
            proxy_pass https://admin-dev.openresty.com.cn;
            proxy_set_header Host admin-dev.openresty.com.cn;
            proxy_ssl_name "admin-dev.openresty.com.cn";
            proxy_ssl_server_name on;
        }

        location /static/ {
            alias fe/dist/static/;
        }
    }
}

启动 nginx

nginx -p $PWD -c conf/travis.conf

其中,-p $PWD 指定当前目录为工作目录。-c 指定配置文件。

reload

找到配置中的 pid 文件,从里面找到 pid

kill -s HUP ${pid}
分类
OpenResty

我的第一段 OpenResty 代码

前端同学需要一个上传文件的后端做一些调试,我刚好有,就给她用了。然后遇到跨域问题,一般来说我会在 PHP 里解决,不过这次我想挑战一下 nginx,于是找到这个答案。照做之后,OPTIONS 请求按照预期返回了正确的头,但是 POST 不行。

于是我在群里请教同事,结果被老板批评:

  1. 在 nginx 里,“IF is evil”,因为要在一些特定条件下,它的行为会跟预期完全相反;有时候甚至会引起 segfault
  2. 我厂是 OpenResty,没理由不用 OpenResty

行吧,本来就是为了跨出舒适区,不妨走远一点,OpenResty 就 OpenResty。

0. 用 OpenResty 替换 Nginx

我的系统是 Ubuntu 18.04,所以先按照 官方文档 安装最新版本的 OpenResty。

安装完成之后,可以通过 service --status-all 查看服务列表,不出意外的话,能看到 openresty 服务。

停掉 nginx,然后启动 openresty:

service nginx stop
service openresty start

此时 openresty 的配置位于 /usr/local/openresty/nginx/conf/nginx.conf,默认的 web 服务地址是 /usr/local/openresty/nginx/html/,所以我们需要修改一下,以便继续使用 /var/www/html,也就是之前 nginx 的网页文件。

1. 继续使用 nginx 的配置

修改配置的方式很简单,OpenResty 本来也兼容 nginx 的配置。对于我而言,最简单的做法是干掉 server 的部分,然后加载 nginx 的网站配置,也就是这两句:

include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;

此处会有个小问题:PHP 网站的配置里需要加载 snippets/astcgi-php.conf,默认使用的是相对路径,需要改成绝对路径 /etc/nginx/snippets/fastcgi-php.conf

至于其它配置,比如 gzipssl 等,酌情拷过来即可。

2. 增加配置以自动输出请求头

最后就是写 lua,这部分难度不大,基本就是普通的 lua 和 nginx lua API。写好的配置是这样的:

location / {
  access_by_lua_block {
    if ngx.req.get_method() == 'POST' then
      ngx.header['Access-Control-Allow-Origin'] = '*';
      ngx.header['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS';
      ngx.header['Access-Control-Allow-Headers'] = 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
      ngx.header['Access-Control-Allow-Expose-Headers'] = 'Content-Length,Content-Range';
 
    elseif ngx.req.get_method() == 'OPTIONS' then
      ngx.header['Access-Control-Allow-Origin'] = '*';
      ngx.header['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS';
      ngx.header['Access-Control-Allow-Headers'] = 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
      ngx.header['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS';
      ngx.header['Access-Control-Allow-Headers'] = 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
      ngx.header['Access-Control-Max-Age'] = 1728000;
      ngx.header['Content-Type'] = 'text/plain; charset=utf-8';
      return 204;
    end
  }
  # First attempt to serve request as file, then
  # as directory, then fall back to displaying a 404.
  try_files $uri $uri/ /index.php$args;
}

首先,使用 access_by_lua_block 添加 lua 代码块。这里注意,一定要用 access_by_lua_block,不能用 content_by_lua_block,因为我们后面还要用 proxy_pass 把请求反向代理给 php-fpm 处理,而 content_by_lua_block 就直接返回了。

然后,因为要添加 headers,所以要在能输出其它内容前先输出头。所以这个代码块要放在最前面,前面不能有其它输出。

3. 总结

需求不大,代码也比较简单,不过还是花了大约一天的时间调试。将来可能会写更多的 OpenResty lua,到时候再慢慢分享咯。

另外我也搞不清楚为啥 nginx 这么流行的一个软件竟然有 if 这种问题。有知道的同学请指教。

分类
ghost

Ubuntu 配置 Nginx + Ghost

按照惯例,买好机器,登录进去。建议是境外服务器,可以省掉备案环节。但是不要干坏事哦。

1. 更新系统

apt-get update
apt-get upgrade

2. 创建 ssh-key

ssh-keygen -t rsa -b 4096 -C "my-email@meathill.com"

3. 添加 authorized_keys

使用 ssh key 登录可以大大提升服务器的安全性。

首先,将你的电脑上的公 key 添加到服务器 ~/.ssh/authorized_keys。接着编辑服务器上的 /etc/ssh/sshd_config,禁用密码登录。

ChallengeResponseAuthentication no
PasswordAuthentication no

最后重启 ssh 服务:service ssh restart

4. 安装 Node.js

按照 NodeSource Node.js Binary Distributions 的指引,安装对应版本的 Node.js—— Ghost 要求 LTS,所以目前只能用 v12。

curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -
sudo apt-get install -y nodejs

5. 安装 MySQL/MariaDB

Ghost 默认使用 SQLite 作为数据库,不过生产环境中 SQLite 性能不够,因此建议直接安装并使用成熟可靠的数据库软件。这里建议使用 MariaDB,开源免费。不同版本的 MariaDB 源可以在 MariaDB Repositories 找到,我一般使用清华的源:

sudo apt-get install software-properties-common
sudo apt-key adv --fetch-keys 'https://mariadb.org/mariadb_release_signing_key.asc'
sudo add-apt-repository 'deb [arch=amd64,arm64,ppc64el] https://mirrors.ustc.edu.cn/mariadb/repo/10.5/ubuntu focal main'

# 添加完仓库和 key 之后,就可以安装了
sudo apt update
sudo apt install mariadb-server

MariaDB 的安装过程没有配置密码的环节,可以使用 重置 MariaDB root 密码 一文中介绍的方法先重置 root 密码,然后创建需要的用户和数据库。

create user 'ghost'@'localhost' identified by 'ghost';
create database `ghost`;
grant all on `ghost`.* to 'ghost'@'localhost';

6. 安装 ghost-cli

ghost-cli 是 ghost 提供的命令行管理工具,可以大大减少我们管理 Ghost 实例的时间。

# 安装
npm i ghost-cli -g

# 创建一个目录安装 ghost
cd /var/www
mkdir ghost
cd ghost

Ghost 要求我们不能用 root 用户维护实例,所以这时可能需要创建一个新用户,并赋予其 sudo 权限(方便操作目录权限之类的),我习惯命名为 ghost-admin:

adduser ghost-admin
usermod -aG sudo ghost-admin

接着,切换到 ghost-admin,安装 ghost

su ghost-admin
cd /var/www
# 改变权限
sudo chown -R ghost-admin:ghost-admin ghost
cd /var/www/ghost
ghost install

安装完成之后,执行 ghost ls,可以看到正在运行的 Ghost 实例,就是一切正常了:

+ sudo systemctl is-active ghost_fav-meathill-com
┌──────────────────┬────────────────┬─────────┬──────────────────────┬─────────────────────────┬──────┬─────────────────┐
│ Name             │ Location       │ Version │ Status               │ URL                     │ Port │ Process Manager │
├──────────────────┼────────────────┼─────────┼──────────────────────┼─────────────────────────┼──────┼─────────────────┤
│ my-ghost │ /var/www/ghost │ 3.31.5  │ running (production) │ http://my-ghost.com │ 2368 │ systemd         │
└──────────────────┴────────────────┴─────────┴──────────────────────┴─────────────────────────┴──────┴─────────────────┘

接下来,编辑 config.production.json,把数据库配置和域名配置都写进去,就基本可用了。

7. 安装并配置 Nginx

首先,配置源。目前 Ubuntu 20.04,代号 focal,命令如下:

deb https://nginx.org/packages/ubuntu/ focal nginx
deb-src https://nginx.org/packages/ubuntu/ focal nginx
sudo apt update
sudo apt install nginx

增加配置文件,并进行反向代理,即把外来的访问反向代理给 Ghost 服务:


cp /etc/nginx/site-available/default /etc/nginx/site-available/ghost.conf
ln -s /etc/nginx/site-available/ghost.conf /etc/nginx/site-enabled/ghost.conf

配置文件大体如下:

server {
    listen 80;
    listen [::]:80;

    server_name my-ghost.com;
    root /var/www/ghost/system/nginx-root; # Used for acme.sh SSL verification (https://acme.sh)

    location / {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $http_host;
        proxy_pass http://127.0.0.1:2368;

    }

    location ~ /.well-known {
        allow all;
    }

    client_max_body_size 50m;
}

最后重启 nginx 服务或者重新加载配置即可:

# 一般建议先检查一下配置有没有问题
nginx -t
service nginx restart

总结

这次主要更新了使用 ghost-cli 安装和配置的内容,服务器也改用 MariaDB,新的技术使得安装配置都更简单了。

分类
服务器端

配置 nginx 支持目录别名

我有一个小项目,我们假定它叫 up,是用 PHP 写的私人图床,部署在服务器上,域名是 up.meathill.com。因为是 PHP,不需要编译,直接 `git clone` 仓库然后配置 nginx 指过去就好。

后来想做一个在线预览的功能,是个单页应用,也放在这个仓库里,目录是 `up/fe`,项目用 Vue + Webpack 来做,源码放在 `up/fe/src`,生成的文件放在 `up/fe/dist`。

这样一来,我就需要在原本的 nginx 里配置一个虚拟目录 `/admin`,指向生成的文件。预览的文件路径就是 `/admin/${file}`,所以我还要把未命中的文件重定向到 `index.html

经过反复摸索,最终的配置如下:

{
        location / {
                # 未直接命中的请求都交给根目录里的 index.php 处理
                try_files $uri $uri/ /index.php$args;
        }

        # 默认的 nginx 1.14 + PHP 7.1 + php-fpm 配置
        location ~ .php$ {
                include snippets/fastcgi-php.conf;
                fastcgi_pass unix:/var/run/php/php7.1-fpm.sock;
        }

        # /admin 的访问指向编译后的前端资源
        location /admin {
                alias /mnt/www/uploader/fe/dist;
        }

        # 直接访问的地址解析,正则其实不必要
        location ~ "/admin/(?<page>d{8}-d{6})$" {
                alias /mnt/www/uploader/fe/dist/index.html;
                # 这里的定义很重要,默认是 application/octet-stream 会启动下载
                default_type text/html;
        }
        
        # 把不希望看到的请求屏蔽掉
        location /fe {
                return 404;
        }
}
分类
wp

开发 WordPress 的 nginx 配置

以前比较习惯放在 /etc/nginx/ 里,用 service nginx start 启动(对应 MacOS + brew 就是 /usr/local/etc/nginx/brew services start nginx)。来到我厂后,开始频繁接触 Nginx 和 OpenResty,现在觉得单独放在目录下面比较方便管理,所以改了一个出来。

配合 Makefile,方便日后使用:

  1. 启动服务 make run
  2. 关闭服务 make stop
  3. 重新加载 make reload
  4. 使用 php-fpm
  5. 代理上传资源(/wp-contents/uploads/)到真实服务器
  6. log 在本地
nginx = nginx

.PHONY: run
run:
    mkdir -p logs
    $(nginx) -p $$PWD -c nginx.conf

.PHONY: reload
reload: logs/nginx.pid all
    $(nginx) -p $$PWD -c conf/nginx.conf -t
    kill -HUP `cat $<`

.PHONY: reload
stop: logs/nginx.pid
    $(nginx) -p $$PWD -c conf/nginx.conf -t
    kill -QUIT `cat $<`
error_log logs/error.log error;
pid logs/nginx.pid;

events {
    accept_mutex off;
}

http {

    server {
        listen 9010;
        listen [::]:9010;

        include /usr/local/etc/nginx/mime.types;

        root /Users/meathill/Sites/wp-dev;

        index index.php index.html index.htm index.nginx-debian.html;

        server_name wp-dev.com;

        location / {
            try_files $uri $uri/ /index.php?$args;
        }

        location /wp-content/uploads/ {
            proxy_pass https://blog.meathill.com/wp-content/uploads/;
            proxy_set_header Host blog.meathill.com;
            proxy_ssl_name "blog.meathill.com";
            proxy_ssl_server_name on;
        }

        location ~ \.php$ {
            include /usr/local/etc/nginx/fastcgi.conf;

            fastcgi_pass 127.0.0.1:9999;
        }
    }
}

代码放在 Github wp-dev-env

分类
技术

诡异的 Mac + PHP + Nginx 问题

问题描述

这次是微信公众号开发,本身就有很多问题,也严重加剧了我调试的难度。正在做一个 WordPress 插件,功能是将公众号文章和 WordPress 互相同步。之前一切正常,突然有用户反馈抓不到公众号上的文章了。

顺便说下,插件地址:https://github.com/meathill/wp-plugin-weixin 目前支支持手动部署,完成二维码之后开始做发布版。

分类
服务器端

上 HTTPS

为啥

  1. 更安全。现在经常在公共场合工作,如果登录博客走 http,太不安全。
  2. 更稳定。国内运营商劫持简直丧心病狂,只能 https 解燃眉之急。
  3. 更有逼格。好歹是个工作十几年的老油条,自己博客连个 https 都没,浏览器一直报告“不安全”,看着太低端。

怎么上

简直容易到死。我之所以选在这个时间上 https,就是想等工具链成熟。现在何止成熟,简直傻瓜。

  1. 免费证书有好几家,我选择 Let’s Encrypt,无他,他们家名字好记。
  2. 进入官网,选择 “Get Started” 进入下一页
  3. 简单阅读一下,他们建议使用命令行工具 Certbot
  4. 听人劝吃饱饭,那就用,进入官网,选择 Software -> Nginx,System -> Ubuntu 17.04,自动跳转到使用步骤。
  5. 然后安装 Certbot
  6. certbot --nginx(其实不带参数也行,它会自动检查),启动配置向导,跟着一步一步来。它会自动配置证书、自动修改 nginx、还可以更新证书,基本上一路回车就行。
  7. 修改 /etc/crontab,配置自动更新。按理说证书有效期为90天,差不多3个月更新就行。然而 crontab 只能按月配置,这样算起来一年有好几天会出问题,所以我干脆配置奇数月的第一天更新。

问题

  1. 七牛云需要独立的证书,申请中。
  2. 友言也挂了,看来要用回 WordPress 自己的评论,或者干脆 Disqus。
分类
服务器端

Ubuntu 16.04 搭建 LNMP 开发环境

前天帮人配了台机器,未来可能还要帮人配。在学会用 Docker 之前,先写一篇记录下怎么搭建环境吧。

这篇收费!¥4.99,请阅后自觉打钱。