使用Nginx配合免费的七牛帐号打造自己的CDN加速服务

初次领略nginx的神奇

之前有听说过nginx,当时只不过认为是普通的HTTP前置机一样的服务,确实没想这么好用:反向代理、URL转发、负载均衡,统统集中在这不到2M的软件中,真是让人惊叹。

下载安装nginx

最新版的 nginx 是 nginx-1.11.4
我在本地PC安装测试的是Windows版,Linux版下载和配置是一样的。
下载回来后解压,可以直接鼠标点击nginx.exe运行。(PS:运行之前保证80端口是空闲的)
运行后一闪而过,根本不知道发生了什么。这时候请打开浏览器,输入https://localhost,如果看到nginx的欢迎页面,说明刚才双击nginx.exe已经产生了作用,nginx服务器已经在后台帮你默默运行着一个超轻量级的WEB服务器。

配置nginx实现反向代理Tomcat

OK,目前我们运行的nginx还只能给浏览器响应简单的HTML、CSS、js等静态资源,下面我们通过简单几行配置,让ngnix与处理J2EE的tomcat进行连通,当客户端浏览器访问指定格式的URL时,由nginx统一接管,然后nginx主动访问本地或远程的Tomcat(本地的tomcat需运行在另一个端口上,不要与nginx冲突),得到响应后,再由nginx转发给客户端浏览器,这个流程就叫做「反向代理(Reverse Proxy)」
具体实现方法如下:

  • 设置Tomcat的端口号,避开80端口,使用8080,or any others you like.设置tomcat的conf文件夹下的server.xml,此处不表。
  • 对nginx的配置文件nginx.conf进行修改,此配置文件在nginx文件夹中的conf文件夹中:
http {
    include       mime.types;
    default_type  application/octet-stream;

    sendfile        on;
    keepalive_timeout  65;

    server {
        listen       80;
        server_name  localhost;

        #转交所有URL给tomcat
        location / {
            proxy_pass https://localhost:8080;
        }

        #下面这个配置将URL指向resources文件夹的路径全部转发给了远程CDN
        location ^~ /resources/ {
            rewrite /resources/(.*) https://files.hexcode.cn/resources/$1;
            expires 7d;
        }
    }
}

原版的nginx.conf文件里面密密麻麻好多注释,第一次使用确实有点绕脑,也有很多名词不曾接触过,我建议立刻马上去除那些注释,去掉那些注释后,真正有用的配置脚本不超过20行,行行重要,字字玑珠。

location / {
    proxy_pass https://localhost:8080;
}

原版配置文件是用root属性,指向了磁盘路径,这里改成proxy_pass属性,指向domain+端口号组成的URL,表示所有请求使用反向代理,让nginx帮客户端浏览器去https://localhost:8080取数据。

将静态资源转移到七牛CDN上

你一定注意到了上面的配置文件,我写上了这么一段:

#下面这个配置将URL指向resources文件夹的路径全部转发给了远程CDN
location ^~ /resources/ {
    rewrite /resources/(.*) https://files.hexcode.cn/resources/$1;
    expires 7d;
}

别着急,听我慢慢道来:
我们 JBlog 的系统,将所有的js,css,fonts等文件全部放在/resources/文件夹下,当时使用SpringMVC的静态资源语法配置的,但这些资源本质上还是要通过本地Tomcat传输给客户端浏览器的,所有的带宽负载和流量计价都是消耗的本地的,如何将这一部分流量和带宽,转嫁到其他云服务上呢,使用nginx会相当方便。
一旦用了nginx,如果你在学SpringMVC时,遇到配置静态资源这一章,可以直接跳过,为什么?因为你配置好nginx后,指定的静态资源压根跑不到tomcat那边去,在nginx这边就帮你转发走了,而且只要你愿意,你可以把这些静态资源全部放到第三方CDN云服务上去加速,这样做是相当有好处的:第一,你的服务器不再传输这些大块头的CSS,JS还有fonts字体文件,节省了带宽,节约了流量;第二,第三方CDN一般带宽会比你买的VPS的带宽高很多,像七牛免费账户有5M以上的带宽给免费用户,还会帮你把文件放置全国多个节点上,让东南西北的浏览器都能极速下载这些静态资源;第三,一般我们很难在Tomcat层级指定某一个CSS文件的缓存策略,但是使用nginx以后,只要你熟悉正则表达式,你可以对任意通配的URL设置独立的缓存策略,这样客户端在访问你动态页面时你可以设置为禁止缓存,但动态页面中嵌入的CSS,你可以独立设置为缓存个一两天。
回到上面的配置文件,我们将URL中指向/resources/的所有文件,重定向到一个七牛免费域名中,同时我们使用七牛的同步软件 qrsbox将resources文件夹完整同步到云端,这样,nginx可以第一时间阻断访问本地资源的操作,让用户全部转向云端服务器下载这些静态资源。其中(.*)是正则表达式,表示任意字符出现任意次数,后面的$1是与其对应的占位值,指向了.*所指代的实际值。
加速,缓存,节约流量,降低本地服务器的带宽负载,nginx太棒了!
nginx还有更多更强大的功能,比如对于分布式服务非常重要的「负载均衡」,我们这里的JBlog项目因为就一台WEB服务器,而且基本上不会有高并发的可能性,遇到DDOS攻击机会也非常小,所以暂时用不到这个高档玩意儿,有兴趣的朋友可以继续研究。

给editor.md添加七牛上传插件



editor.md 是一款支持markdown的WEB编辑器,用户可以在自己的网站上使用其进行内容编辑,这在之前的博客里有详细的使用介绍。
本文主要介绍如何自定义一个支持七牛上传的editor.md插件。

editor.md 自带图片上传组件

首先介绍一下使用editor.md的自带图片上传组件的配置,这对下面开发七牛上传插件有很大的帮助。
初始化 editor.md 时使用以下配置即可打开图片上传功能。

<div class="editormd" id="id_editormd">
    <textarea class="editormd-markdown-textarea" name="markdown"></textarea>
    <textarea class="editormd-html-textarea" name="html"></textarea>
</div>
<script>
editormd("id_editormd", {
    width : "100%",
    height : 540,
    syncScrolling : "single",
    path : "${rc.contextPath}/resources/editormd/lib/",
    imageUpload : true,
    imageFormats : [ "jpg", "jpeg", "gif", "png", "bmp", "webp" ],
    imageUploadURL : "/uploadfile",
    saveHTMLToTextarea : true
}
</script>

其中的/uploadfile指向了图片上传的后台URL地址,在editor.md进行图片上传时,将构造一个 enctype="multipart/form-data" 的form表单,然后使用iframe的方式向该URL异步POST上传,上传后后台返回一个固定格式json,包含了上传成功与否,以及上传成功后的图片URL信息,json格式如下:{"success":0|1,"message":"xxx","fileName":"imageURL"}

以JAVA为例,编写后台上传URL服务器端代码(SpringMVC):

@RequestMapping(value="/uploadfile",method=RequestMethod.POST)
public ModelAndView upload(HttpServletRequest request, HttpServletResponse response, @RequestParam(value = "editormd-image-file", required = false) MultipartFile attach){
    String rootPath = request.getSession().getServletContext().getRealPath("/resources/upload/");
    response.setHeader( "Content-Type" , "text/html" );
    ModelAndView mv = new ModelAndView("upload_result");
    try {
        File filePath=new File(rootPath);        
        /**
         * 文件路径不存在则需要创建文件路径
         */
        if(!filePath.exists()){
            filePath.mkdirs();
        }

        //最终文件名
        File realFile=new File(rootPath + File.separator + UUID.randomUUID().toString() + ".jpg");
        FileUtils.copyInputStreamToFile(attach.getInputStream(), realFile);

        mv.addObject("success", 1);
        mv.addObject("message", "上传成功");
        mv.addObject("fileName", realFile.getName());
    } catch (Exception e) {
        mv.addObject("success", 0);
        mv.addObject("message", "上传失败,异常信息:" + e.getMessage());
    }

    return mv;
}

//upload_result.json:
//{"success": ${success}, "message":"${message}"<#if fileName ??>,"url":"/resources/upload/${fileName}"</#if>}

以上就是使用editor.md图片上传的整个流程了。

七牛云存储

七牛云存储 是国内比较著名的云存储服务,为免费用户提供高达10G的存储空间,API齐全,文档够详细,上传下载速度快,丰富的水印、缩放、防盗链等功能。
使用云存储对于博客类网站是非常不错的主意,节省了大量的带宽和流量,毕竟我们购买的VPS,包括阿里云、腾讯云等产品,都是流量计费比包年付费划算的,如果采用流量计费的方式,图片流量绝对会比HTML文本大得多,而如果采用包年包月计费,这时候带宽是一定的,加载图片会占用大量带宽,从而影响页面加载速度。此外在进行博客搬家时,我们无需面对高达几百M甚至上G的上传文件夹,仅需将WEB代码和mySQL数据库拷贝一份出来即可。综上所述,为了节省带宽和流量资源,提高网页加载速度,并且简化搬家过程,使用云服务器进行图片资源的第三方存储,是绝对有好处的。

为 editor.md 设计七牛云存储插件

我百度了一下 editor.md 的云存储插件,并没有发现任何可以拿来直接用的插件,于是萌生了自己开发editor.md插件的想法。

上传流程

七牛的上传流程是这样的:
第一步:浏览器客户端向本地URL发送GET请求,请求一串上传令牌token,服务器端通过七牛的公钥、密钥和空间名称,以及自定义的上传策略,构造一串token给客户端,这个token是一组加密过的散列码,长度不固定。这个过程推荐使用ajax请求和json传输。
第二步:浏览器将拿到的令牌token和要上传的图片一起通过multipart加密form传输给七牛服务器,七牛服务器对token使用密钥验证和解析,得到是谁上传的,上传到哪个资源空间等信息,然后返回一个处理结果给浏览器。这其中包含上传成功与否,图片上传后的URL后缀地址,错误信息等。
以上两步即完成了七牛云服务器上传的全部,下面着手来开发:

插件入口

首先editor.md提供了自定义插件接口,这对我们编写插件带来了可行性性,官方的自定义插件文档在这里:https://pandao.github.io/editor.md/examples/define-plugin.html
依照这个示例程序,改造editor.md的初始化配置函数:

editormd("domId", {
    width : "100%",
    height : 540,
    syncScrolling : "single",
    path : "${rc.contextPath}/resources/editormd/lib/",
    imageUpload : true,
    imageFormats : [ "jpg", "jpeg", "gif", "png", "bmp", "webp" ],
    imageUploadURL : "/uploadfile",
    saveHTMLToTextarea : true,
    previewTheme : "dark",
    toolbarIcons : function() {
    return  ["bold", "del", "italic", "hr", "image", "qiniu", "table", "datetime", "|", "preview", "watch", "|", "fullscreen"];
  },
  //配置七牛上传插件
  toolbarIconsClass : {
      qiniu : "fa-cloud-upload"
  },
  toolbarHandlers : {
      qiniu : function(cm, icon, cursor, selection) {
          this.imageDialogQiniu();
      }
  },
  qiniuTokenUrl : "/getQiniuToken",                        //本地服务器获取七牛token的url
  qiniuPublishUrl : "https://files.hexcode.cn/"    //远程七牛服务器个人发布地址
});

可以看到上面我们配置了一个一个名为qiniu的插件,并且定义了它的入口函数:imageDialogQiniu();,并添加了两个配置字符串qiniuTokenUrlqiniuPublishUrl,这两个字符串第一个是本地服务器URL,用来从本地获取上传令牌,这个后面会讲;第二个URL是在你注册七牛帐户后七牛分配给你的默认空间URL前缀,你可以付费开通正式帐号来自定义这个前缀,我暂时没有特别的需求,先使用的七牛免费功能,等后续有更多需求的时候再考虑是否成为七牛付费会员。

七牛空间前缀及上传令牌


注册成为七牛会员后,进入七牛控制台,添加一个「对象存储」资源,如图所示,右边的「我的资源」中会出现这个资源空间,点击这个资源,即可看到自己的上传空间的URL前缀了,上面讲过,免费用户的空间前缀是随机的HASH码,付费以后可以自定义这个前缀,界面如下:

下面我们来查看七牛分配给我们构造上传令牌时所需的公钥和密钥:

右上方「个人面板」->「密钥管理」即可查看,其中AK是公钥,SK是密钥,虽说现在的RSA加密机制中公钥是可以传播的,密钥是不可以传播的,但我还是建议这两个密钥都不要随意公布,所以这里我隐藏了。
好了,URL前缀,公钥,密钥都有了,七牛给我们的这三个看上去像乱码,却蕴含特殊意义的字符串都拿到手了,继续我们的开发吧。

编写插件JS


在editor.md的文档结构中,plugins文件夹是所有的自带的插件,我们添加七牛插件也是如此,添加一个文件夹,名为image-dialog-qiniu,并创建image-dialog-qiniu.js
其中的代码我大部分模仿了原有的image-dialog插件,但是改造了其有点不知所谓的iframe上传方法,使用我偏爱的jQuery ajax post异步上传,掌握这些知识点的朋友应该能够看明白,代码如下:

/*!
 * Image (upload) dialog By Qiniu plugin for Editor.md
 *
 * @file        image-dialog-qiniu.js
 * @author      newflydd@189.cn
 * @version     1.3.4
 * @updateTime  2016-09-02
 * {@link       https://www.hexcode.cn}
 * @license     MIT
 */

(function() {

    var factory = function (exports) {

        var pluginName   = "image-dialog-qiniu";

        exports.fn.imageDialogQiniu = function() {

            var _this       = this;
            var cm          = this.cm;
            var lang        = this.lang;
            var editor      = this.editor;
            var settings    = this.settings;
            var cursor      = cm.getCursor();
            var selection   = cm.getSelection();
            var imageLang   = lang.dialog.image;
            var classPrefix = this.classPrefix;
            var iframeName  = classPrefix + "image-iframe";
            var dialogName  = classPrefix + pluginName, dialog;
            var ajaxToken    = "";        //向本地服务器请求七牛的上传token

            cm.focus();

            var loading = function(show) {
                var _loading = dialog.find("." + classPrefix + "dialog-mask");
                _loading[(show) ? "show" : "hide"]();
            };

            if (editor.find("." + dialogName).length < 1) {
                var guid   = (new Date).getTime();
                var action = settings.imageUploadURL + (settings.imageUploadURL.indexOf("?") >= 0 ? "&" : "?") + "guid=" + guid;

                if (settings.crossDomainUpload)
                {
                    action += "&callback=" + settings.uploadCallbackURL + "&dialog_id=editormd-image-dialog-" + guid;
                }

                var dialogContent = ( (settings.imageUpload) ? "<form id=\"qiniuUploadForm\" method=\"post\" enctype=\"multipart/form-data\" class=\"" + classPrefix + "form\" onsubmit=\"return false;\">" : "<div class=\"" + classPrefix + "form\">" ) +                                      
                                        "<label>" + imageLang.url + "</label>" +
                                        "<input type=\"text\" data-url />" + (function(){
                                            return (settings.imageUpload) ? "<div class=\"" + classPrefix + "file-input\">" +
                                                                                "<input type=\"file\" name=\"file\" accept=\"image/*\" />"            +
                                                                                "<input type=\"submit\" value=\"七牛上传\" click=\"alert('dd')\" />" +
                                                                            "</div>" : "";
                                        })() +
                                        "<br/>" +
                                        "<input name=\"token\" type=\"hidden\" value=\"" + ajaxToken + "\">"    +        //七牛的上传token
                                        "<label>" + imageLang.alt + "</label>" +
                                        "<input type=\"text\" value=\"" + selection + "\" data-alt />" +
                                        "<br/>" +
                                        "<label>" + imageLang.link + "</label>" +
                                        "<input type=\"text\" value=\"https://\" data-link />" +
                                        "<br/>" +
                                    ( (settings.imageUpload) ? "</form>" : "</div>");

                dialog = this.createDialog({
                    title      : imageLang.title,
                    width      : (settings.imageUpload) ? 465 : 380,
                    height     : 254,
                    name       : dialogName,
                    content    : dialogContent,
                    mask       : settings.dialogShowMask,
                    drag       : settings.dialogDraggable,
                    lockScreen : settings.dialogLockScreen,
                    maskStyle  : {
                        opacity         : settings.dialogMaskOpacity,
                        backgroundColor : settings.dialogMaskBgColor
                    },
                    buttons : {
                        enter : [lang.buttons.enter, function() {
                            var url  = this.find("[data-url]").val();
                            var alt  = this.find("[data-alt]").val();
                            var link = this.find("[data-link]").val();

                            if (url === "") {
                                alert(imageLang.imageURLEmpty);
                                return false;
                            }

                            var altAttr = (alt !== "") ? " \"" + alt + "\"" : "";

                            if (link === "" || link === "https://")
                            {
                                cm.replaceSelection("![" + alt + "](" + url + altAttr + ")");
                            }
                            else
                            {
                                cm.replaceSelection("[![" + alt + "](" + url + altAttr + ")](" + link + altAttr + ")");
                            }

                            if (alt === "") {
                                cm.setCursor(cursor.line, cursor.ch + 2);
                            }

                            this.hide().lockScreen(false).hideMask();

                            return false;
                        }],

                        cancel : [lang.buttons.cancel, function() {
                            this.hide().lockScreen(false).hideMask();

                            return false;
                        }]
                    }
                });

                dialog.attr("id", classPrefix + "image-dialog-" + guid);

                if (!settings.imageUpload) {
                    return ;
                }

                var fileInput  = dialog.find("[name=\"file\"]");

                var submitHandler = function() {
                    $.ajax({
                        url         : settings.qiniuTokenUrl,
                        type     : "post",
                        dataType : "json",
                        timeout  : 2000,
                        beforeSend : function(){loading(true);},
                        success  : function(result){
                            if(result.resultCode == 0){
                                ajaxToken = result.data;

                                if(ajaxToken === ""){
                                    loading(false);
                                    alert("没有获取到有效的上传令牌,无法上传!");
                                    return;
                                }
                                dialog.find("[name=\"token\"]").val(ajaxToken);
                                var formData = new FormData( $("#qiniuUploadForm")[0] );
                                dialog.find("[name=\"token\"]").val();    //隐藏令牌
                                $.ajax({
                                    url: 'https://upload.qiniu.com/' ,  
                                    type: 'POST',  
                                    data: formData,
                                    dataType: "json",
                                    beforeSend:function(){loading(true);},
                                    cache: false,
                                    contentType: false,
                                    processData: false,
                                    timeout : 30000,
                                    success: function (result) {
                                        dialog.find("[data-url]").val(settings.qiniuPublishUrl + result.key);
                                    },
                                    error : function(){alert("上传超时");},
                                    complete:function(){loading(false);}
                                });
                            }
                            else
                                alert(result.message);
                        }
                    });
                };

                dialog.find("[type=\"submit\"]").bind("click", submitHandler);

                fileInput.bind("change", function() {
                    var fileName  = fileInput.val();
                    var isImage   = new RegExp("(\\.(" + settings.imageFormats.join("|") + "))$"); // /(\.(webp|jpg|jpeg|gif|bmp|png))$/

                    if (fileName === "")
                    {
                        alert(imageLang.uploadFileEmpty);

                        return false;
                    }

                    if (!isImage.test(fileName)){
                        alert(imageLang.formatNotAllowed + settings.imageFormats.join(", "));
                        return false;
                    }

                    dialog.find("[type=\"submit\"]").trigger("click");
                });
            }

            dialog = editor.find("." + dialogName);
            dialog.find("[type=\"text\"]").val("");
            dialog.find("[type=\"file\"]").val("");
            dialog.find("[data-link]").val("https://");

            this.dialogShowMask(dialog);
            this.dialogLockScreen();
            dialog.show();

        };

    };

    // CommonJS/Node.js
    if (typeof require === "function" && typeof exports === "object" && typeof module === "object")
    {
        module.exports = factory;
    }
    else if (typeof define === "function")  // AMD/CMD/Sea.js
    {
        if (define.amd) { // for Require.js

            define(["editormd"], function(editormd) {
                factory(editormd);
            });

        } else { // for Sea.js
            define(function(require) {
                var editormd = require("./../../editormd");
                factory(editormd);
            });
        }
    }
    else
    {
        factory(window.editormd);
    }

})();

后面一段的// CommonJS/Node.js是原模板自带的,可能是为Node.js编写的WEB服务器提供功能,我们用不到,这里就不深究了。

本地服务器令牌生成

在刚刚那个插件JS中,我们首先向本地服务器索取上传令牌,这个令牌是一串hash字符串,内容包括了从申请令牌到上传图片成功或者失败之间的上传时限,上传的空间,以及更多的上传策略。构造这一串令牌的代码在本地WEB服务器中实现,以JAVA为例:

/**
 * 七牛上传令牌获取
 * @return
 * @throws JblogException 
 */
@RequestMapping(value="/getQiniuToken",method=RequestMethod.POST)
public ModelAndView qiniu() throws JblogException{
    Result result = new Result();

    if(StringUtils.isEmpty(BlogConfig.QN_ACCESSKEY) || StringUtils.isEmpty(BlogConfig.QN_SECRETKEY)){
        result.setCode(Result.FAIL);
        result.setMessage("数据库中没有正确的七牛服务器密钥,无法生成令牌");
        return new ModelAndView("ajaxResult", "result", result);
    }

    Auth auth = Auth.create(BlogConfig.QN_ACCESSKEY, BlogConfig.QN_SECRETKEY);
    String token = auth.uploadToken(
        "blog-images",    //空间名称
        null,            //key,最终资源名称,一般为空,让服务器自己生成
        3600,            //3600秒,上传时限
        new StringMap()    //其他上传策略
            .put("saveKey", UUID.randomUUID().toString() + "$(ext)")
    );

    result.setData("\"" + token + "\"");

    return new ModelAndView("ajaxResult", "result", result);
}

其中的Auth是七牛官网提供的jar包中的,不需要直接下载,可以使用maven或者gradle获取,七牛已经将这个jar提交到了maven国际公共库了,介绍文档在这里:https://developer.qiniu.com/code/v7/sdk/java.html
我用的gradle,直接添加:

compile 'com.qiniu:qiniu-java-sdk:7.x.+'

到此,我们整个的editor.md七牛插件就编写好了。如下方所示:

回顾一下开发步骤

  1. 在editor.md的初始化函数中添加插件的描述。
  2. 在editor.md的plugins文件中添加相关的插件文件夹和插件JS,js代码可以根据插件模板书写。
  3. 到七牛官网注册,并在个人中心添加资源空间,收集三个字符串数据:资源空间的url前缀,AK公钥,SK私钥。
  4. 编写本地服务器端代码,使用AK和SK构造token上传令牌,构造时使用的类来自七牛JAVA文档。

其中最复杂的是第二步,我所公布的插件JS篇幅比较长,需要耐心观察。其中的知识点包括jQuery;动态生成form;ajax提交等,这些都是互联网应用开发的基础知识点。

JBlog图标设计

在 [https://www.logoko.com.cn/](https://www.logoko.com.cn/) 上为JBlog设计了一款图标。 上传到七牛服务器上。 ![](https://files.hexcode.cn/f1500c4f-6dda-4105-8261-0440c79cffee.png)

丁丁生于 1987.07.01 ,30岁,英文ID:newflydd

  • 现居住地 江苏 ● 泰州 ● 姜堰
  • 创建了 Jblog 开源博客系统
  • 坚持十余年的 独立博客 作者
  • 大学本科毕业后就职于 中国电信江苏泰州分公司,前两年从事Oracle数据库DBA工作,两年后公司精简技术人员,被安排到农村担任支局长(其本质是搞销售),于2016年因志向不合从国企辞职,在小城镇找了一份程序员的工作。
  • Git OSChina 上积极参与开源社区