小型大写字母的用武之处

对英文字体排版稍有了解的话,就会知道在大写字母和小写字母之外,还存在着一种特殊的类型:小型大写字母(Small Caps)。

大写字母:HELLO WORLD
小写字母:hello world
小型大写字母:Hᴇʟʟᴏ ᴡᴏʀʟᴅ

这样看可能并不能明显地突显出小型大写字母的特征。那么,如果你有看过英文版的圣经的话,在全书中提到「ʟᴏʀᴅ」的地方,都使用的是小型大写字母。

image

可以看到,这些「ʟᴏʀᴅ」在字形上是大写字母,然而,在字高上,却与小写字母相同。小型大写字母被创造出来的初衷有两点,其一,是为了使标题更具有装饰性和艺术感;其二,是为了在部分排版中(尤其是密集的正文排版),替代大写字母的存在,从而减弱对视觉的干扰。

这么说可能有些抽象,那我们就一起来看看小型大写之母经常出没的地方。需要注意的是,下文中提到的一些示例,很多只是出于通用的实践或大多数设计师个人的喜好,而并非强制性的规范。

减弱大写字母对视觉的冲击

capitals_small_v_big

英文中存在着许多首字母缩写(acronyms,例如 WTO 是 World Trade Organization 的首字母缩写)和简写(abbreviations,例如 TV 是 Television 的简写)。这些缩写或简写往往需要大写,然而,你会发现当它们出现在密集排版的正文中,对视觉有很大的干扰,使阅读的重点完全落在了这些词上。

而将这些大写字母转化为小型大写字母后,视觉上的冲击就被明显减弱了,人的阅读注意力也能重新回到文本和内容上。不过,在实践中,许多设计师仅对三个字母以上的缩写使用小型大写字母。但这并不是约定成俗的,在一部分出版社的排版规范中,也规定了文中出现的「ᴀᴅ」、「ʙᴄ」、「ᴀᴍ」、「ᴘᴍ」皆采用小型大写字母。

capitals_time_smallcaps1

装饰性的封面和标题

51+ERtqCF9L

在许多书的封面设计(往往是副标题和作者署名),以及正文中的章节标题处,都会使用小型大写字母。一种更为常见的用法是,同时搭配大写字母与小型大写字母,单词的首字母使用大写字母,其后跟随小写字母,往往可以使字体的设计显得更具有装饰感。

capitals_headlines

正文中的引用

在一些规范中,如果在正文内引用了本书的其它部分章节内容,或在法律文书中援引一些人名和书籍时,对被引用的部分不使用斜体或双引号,而使用小型大写字母。如《世界图书百科全书》中提到「See Nᴏ-Fᴀᴜʟᴛ Iɴsᴜʀᴀɴᴄᴇ」,意即让读者去参阅「Nᴏ-Fᴀᴜʟᴛ Iɴsᴜʀᴀɴᴄᴇ」这个章节。

指代姓氏或非西文顺序的姓氏

在一些语言中,如果一个人的姓氏非常长,往往会用小型大写字母来表示后文会经常使用的简写。如西班牙语写作的《堂吉诃德》中,「Don Qᴜɪxᴏᴛᴇ de La Mancha」意为「来自曼查的骑士吉诃德大人」,在后文中仅用「Qᴜɪxᴏᴛᴇ」来代指。而对于像中文这样,将姓氏排在名前面的顺序,有时也会使用小型大写字母,来特意标注出哪部分是姓氏,如「Mᴀᴏ Zedong」。

用于头部引起视觉注意

capitals_plays

在剧本中,不同角色的台词,其另起一行的人物名称往往会使用小型大写字母,来引起视觉上的注意,而又不显得过于突兀。

capitals_chapter_start_small

在章节的起首,英文字体往往也会有多样的处理。除了纵向上横跨数行的首字母放大,另一种常见的处理,则是使用小型大写字母。根据设计师的个人喜好,采用小型大写字母处理的范围也不尽相同,从首个单词,到首句话,再到首行,都有可能。

Reference:
- http://www.bergsland.org/2012/07/book-production/typography/the-use-of-small-caps-is-required/
- https://ilovetypography.com/2008/02/20/small-caps/
- http://theworldsgreatestbook.com/book-design-part-5/
- https://en.wikipedia.org/wiki/Small_caps

0

React Native 项目整合 CodePush 之完全指南

本文使用的环境:

  • React@16.3.1
  • React Native@0.55.4
  • react-native-code-push@5.3.4
  • Android SDK@23
  • Android Build Tool@23.0.3
  • Gradle@2.14.1
  • Android Gradle Plugin@2.2.3

Why CodePush?

CodePush 是微软提供的一个热更新前后台方案,它对 React Native 项目有很好的支持。

目前针对 React Native 的 hot update 方案有许多,但是 CodePush 是最成熟稳定的方案,它最大的特点是提供了完整的后台工具。它主要的优点是:

  • 微软出品,大厂保证
  • 良好的多环境支持(Testing,Staging, Production)
  • 灰度发布、自动回滚等等特性
  • 良好的数据统计支持:下载、安装、出错一目了然
  • 强大的 CLI 工具,一个终端搞定全部流程

由于 React Native 执行的是脚本 js 文件,对热更新有天然的亲和,有余力的团队可以尝试实现自己的框架,一个简单的实现思路是:

  • 修改加载 jsBundle 的代码,转而从指定的本地存储位置去加载。如果没有,下载 bundle, 并且本次打开使用 app 包中的 bundle。
  • 如果找到 jsBundle 文件,调用 api 比较版本号,如果不一致,则从指定服务器下载最新的 bundle 进行替换。
  • 通过反射调用私有方法,在下载完成的回调中更新运行时资源,从而能立即看到更新的效果。
  • 使用类似 google-diff-match-patch 的 diff 工具,生成差异化补丁,不必下载完整 bundle,从而大大减小补丁包体积。

网上有很多资料和源码,这里就不细述了。

后台配置

为了使用 Code Push 发布热更新,我们需要向微软服务注册我们的应用。这部分工作微软提供了强大的命令行工具:CodePush CLI

CodePush CLI

安装 cli 工具

npm 全局安装:

npm install -g code-push-cli

关联账号

使用命令

code-push register

注册一个账号,可以直接使用 GitHub 账号授权,完成后将 token 复制回命令行中。

授权返回的token

使用 whoami 查看登录状态:

code-push whoami

注册应用

登录成功后,我们注册一个app:

code-push app add 你的App名称 android react-native

注意一定要为 Android 和 iOS 分别注册,两者的更新包内容会有差异。

注册成功

查询状态

每个 App 有不同的运行时环境,比如 Production,Staging等,我们也可以配置自己的环境。查看 App 的不同环境和部署状况:

code-push deployment ls 注册的app名称

查询状态

目前我们还没有发布任何更新,所以表中的状态是空的。

到这里就完成了后端的基本配置。

App端配置

版本兼容

安装 Code Push 环境前首先要 check 版本的兼容性问题,不同的RN版本需要使用不同的 Code Push,原则上我们建议将 RN 和 CodePush 都升级到最新版本。

下表是官方文档中的兼容性说明:

React Native version(s) Supporting CodePush version(s)
<0.14 Unsupported
v0.14 v1.3 (introduced Android support)
v0.15-v0.18 v1.4-v1.6 (introduced iOS asset support)
v0.19-v0.28 v1.7-v1.17 (introduced Android asset support)
v0.29-v0.30 v1.13-v1.17 (RN refactored native hosting code)
v0.31-v0.33 v1.14.6-v1.17 (RN refactored native hosting code)
v0.34-v0.35 v1.15-v1.17 (RN refactored native hosting code)
v0.36-v0.39 v1.16-v1.17 (RN refactored resume handler)
v0.40-v0.42 v1.17 (RN refactored iOS header files)
v0.43-v0.44 v2.0+ (RN refactored uimanager dependencies)
v0.45 v3.0+ (RN refactored instance manager code)
v0.46 v4.0+ (RN refactored js bundle loader code)
v0.46-v0.53 v5.1+ (RN removed unused registration of JS modules)
v0.54-v0.55 v5.3+ (Android Gradle Plugin 3.x integration)

安装包

使用命令:

npm info react-native-code-push

来查看包相关信息。

我们建议始终将RN、React以及一些相关库升级到最新版本。在根目录下使用命令:

npm install --save react-native-code-push

来安装最新版本的 CodePush。

也可以参照上面的兼容性表格,安装指定版本:

npm install --save react-native-code-push@5.1.4

工程配置(Android)

如果工程创建的时候比较早,可能是使用命令create-react-native-app来创建的,则需要在根目录执行:

npm run eject

来改变工程结构,防止后面的兼容性问题。

配置安卓工程,官方提供了两种途径:

  • 使用命令行工具rnpm(现在已经被整合到React Native CLI工具中了)。执行
react-native link react-native-code-push
  • 手动配置

如果你是新手,或者对 gradle、安卓工程结构不了解,我们强烈建议执行一次手动配置,帮助理解到底发生了什么。

手动配置

step 1

android/settings.gradle文件中添加:

include ':app', ':react-native-code-push'
project(':react-native-code-push').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-code-push/android/app')

这个文件定义了哪些 module 应该被加入到编译过程,对于单个 module 的项目可以不用需要这个文件,但是对于 multiModule 的项目我们就需要这个文件,否则 gradle 不知道要加载哪些项目。这个文件的代码在初始化阶段就会被执行。

我们添加的内容告诉 gradle:去 node_modules 目录下的 react-native-code-push 加载 CodePush 子项目。

step 2

android/app/build.gradle 中的 dependencies 方法中添加依赖:

...
dependencies {
    ...
    compile project(':react-native-code-push')
}

这样就能在主工程中引用到 CodePush 模块了。

step 3

继续在 android/app/build.gradle 中,添加在编译打包阶段 CodePush 需要执行的 task 引用:

...
apply from: "../../node_modules/react-native-code-push/android/codepush.gradle"
...

这段代码其实就是调用了 project 对象的 apply 方法,传入了一个以 from 为 key 的 map。完整写出来就是这样的:

project.apply([from: '../../node_modules/react-native-code-push/android/codepush.gradle'])

apply fromapply plugin的区别在于,前者是从指定 url 去加载脚本文件,后者则用是从仓库拉取 plugin id 对应的二进制执行包。

step 4

CodePush 发布有各种环境(deployment),默认有 Staging 和 Production,我们需要在 buildType 中配置对应的环境,并且设置 PushKey,从而让 App 端的 CodePush RunTime 根据不同的健值来下载正确的更新包。

查询各个环境 Key 的方法是使用上文安装的 CLI 工具:

code-push deployment ls App名称 -k

查询CodePushKey

上表中的 Deployment Key 就是对应环境的 Key 值了。

android/app/build.gradle 中,配置 buildTypes:

buildTypes {

    // 对应Production环境
    release {
        ...
        buildConfigField "String", "CODEPUSH_KEY", '"从上述结果中复制的production值"'
        ...
    }

    // 对应Staging环境
    releaseStaging {
        // 从 release 拷贝配置,只修改了 pushKey
        initWith release
        buildConfigField "String", "CODEPUSH_KEY", '"从上述结果中复制的stagingkey值"'
    }

    debug {
        buildConfigField "String", "CODEPUSH_KEY", '""'
    }
}

注意这里不同 buildType 的命名,Staging 环境对应的 buildType 就叫 releaseStaging,要符合这样的命名规范。

Debug 环境虽然用不到 CodePush, 但是也要配置空的 Key 值,否则会报错。

step 5

处理完引用关系后,我们修改 MainApplication.java,在 App 执行时启动 CodePush 服务:

// 声明包
import com.microsoft.codepush.react.CodePush;

public class MainApplication extends Application implements ReactApplication {

    private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
        ...
        // 重写 getJSBundleFile() 方法,让 CodePush 去获取正确的 jsBundle
        @Override
        protected String getJSBundleFile() {
            return CodePush.getJSBundleFile();
        }

        @Override
        protected List<ReactPackage> getPackages() {
            return Arrays.<ReactPackage>asList(
                new MainReactPackage(),
                // 创建一个CodePush运行时实例
                new CodePush(BuildConfig.CODEPUSH_KEY, MainApplication.this, BuildConfig.DEBUG)
                ...
            );
        }
    };
}

js端引入 Code Push

配置完项目工程后,我们将 CodePush 引入到 js 端。

首先将 App 的根组件包裹在 CodePush 中:

import codePush from "react-native-code-push";

AppRegistry.registerComponent('BDCRM', () => codePush(App));

CodePush 会在 App 启动后自动去 check 和更新最新的版本,我们可以添加一些配置,让它在进入后台的时候也执行检查:

let codePushOptions = { checkFrequency: codePush.CheckFrequency.MANUAL };
AppRegistry.registerComponent('BDCRM', () => codePush(codePushOptions)(App));

CodePush js端的 api 不多,我们可以用这些 api 控制更新的一系列流程,常用的有:

// 检测是否有更新包可用
codePush.checkForUpdate(deploymentKey: String = null, handleBinaryVersionMismatchCallback: (update: RemotePackage) => void): Promise<RemotePackage>;

// 获取本地最新更新包的属性
codePush.getCurrentPackage(): Promise<LocalPackage>;

// 重启app(即使不用在 Hot Updating,也挺有用的)
codePush.restartApp(onlyIfUpdateIsPending: Boolean = false): void;

// 手动进一次更新
codePush.sync(options: Object, syncStatusChangeCallback: function(syncStatus: Number), downloadProgressCallback: function(progress: DownloadProgress), handleBinaryVersionMismatchCallback: function(update: RemotePackage)): Promise<Number>;

更多详细信息见文档

使用 CodePush CLI 发布更新

完成前后端的配置,打包发布应用后,后续的改动我们就能通过 CLI 工具来发布啦!

升级前首先要 check:

  • 应用的版本号要有更新(app/build.gradle: defaultConfig/versionName)
  • js bundle 要有改动,Code Push 会 diff 前后版本,如果代码一致会认为是无效的更新包

打开终端,进入到工程目录,完整发布命令是:

code-push release-react <appName> <platform>
[--bundleName <bundleName>]
[--deploymentName <deploymentName>]
[--description <description>]
[--development <development>]
[--disabled <disabled>]
[--entryFile <entryFile>]
[--gradleFile <gradleFile>]
[--mandatory]
[--noDuplicateReleaseError]
[--outputDir <outputDir>]
[--plistFile <plistFile>]
[--plistFilePrefix <plistFilePrefix>]
[--sourcemapOutput <sourcemapOutput>]
[--targetBinaryVersion <targetBinaryVersion>]
[--rollout <rolloutPercentage>]
[--privateKeyPath <pathToPrivateKey>]
[--config <config>]

命令参数很多,但用途都一目了然,嫌每次打麻烦的话,做成脚本也可以。

一般来说,我们发布应用首先会在测试环境进行稳定性测试,通过后再发布到生产环境中:

  • 打包发布 Staging 环境
code-push release-react 应用名 --platform android --deploymentName Staging --description "修复一些bug"

这样,我们 Staging 环境就可以收到更新推送啦,具体加载新 bundle 的实际,和我们在应用中配置的策略有关,上文已经介绍过了。

  • 测试 ok 后,提升(Promoting)到 Production 环境,并且进行灰度20%发布
code-push promote 应用名 Staging Production --rollout 20%
  • 在生产环境验证 ok,使用 patch 将灰度修改为100%,进行全网发布:
code-push patch 应用名 Production -rollout 100%

以上就是按照 测试 - 灰度 - 全部发布 步骤的一个典型 CodePush 发布工作流。

总体来说,CodePush 能满足我们灰度发布 React Native 应用的大部分需求了,由微软提供的服务器端支持可以节省很多工作,是一个成熟可靠的方案。如果要说缺点,可能有几个需要考虑一下:

  • 服务器速度,国内网络状况可能会影响下发的成功率和效率。
  • 污染代码,在 js 端必须将根节点包裹到 CodePush 模块中去,污染了代码。
  • 冗余,如果只是想要简单的下发小体积的 js bundle,CodePush 显得太“重”,过于冗余了,这时候用轻量化的方案更好。

总之,我们根据自己项目的需要去进行选型就好了!

更多细节,可以参考文档

0

震惊!JavaScript 竟然可以类型推断!

作为弱类型的 JavaScript 写起来爽,维护起来更

—— 鲁迅·沃梅硕果

近几年,前端技术的发展可以用 Big Boom 来形容,因此 JavaScript 也被大规模的运用在项目中,由此也产生了代码的维护问题,所谓 动态类型一时爽,代码重构火葬场

其实不仅仅是代码重构,在日常开发中也能感受到弱类型语言的不足所带来的不便之处。举个例子,现在有个函数 renderUserList , 作用是将用户列表显示在界面上

function renderUserList(el, userList) {
  let html = '';
  for (const user of userList) {
    html += `<div>
    姓名:${user.age}
    年龄:${user.age}
    </div>`
  }
  el.innerHTML = html;
}

我敢打赌,大家在写这种类型函数的时候,都是在盲写,因为我们不知道传入的 eluserList 到底是什么类型。更不知道 el 下面有哪些方法,写的时候都如此费劲,跟别谈维护了。其实我们可以通过一些简单的操作,让这个函数写起来更轻松,就像下面一样:

类型提示1

类型提示2

那么,到底是怎么实现的呢?接下来就要介绍本文的主角 JSDocVSCode

JSDoc是一个根据 JavaScript 文件中注释信息,生成 JavaScript 应用程序或库、模块的 API 文档 的工具。你可以使用他记录如:命名空间,类,方法,方法参数等。

通俗的讲,JSDoc 是 JavaScript 注释规范的一种,VSCode 利用 JSDoc 规范的特点,配合 typescript 实现了“类型提示”,所以在 VSCode 中基本上是 开箱即用 的,而对于非内置对象,比如 jQuery 的 $,lodash 的 _ 等,则需要单独下载对应的声明文件。

不过实际开发中,在 window 和 mac 上,还是有些差别的,mac 版的 VSCode 会去检查代码,然后自动下载对应的声明文件存放在 ~/Library/Caches/typescript/ (猜测是自动下载的),而 windows 则需要开发者手动通过 npm 去安装需要的声明文件,文末也会提到如何使用声明文件。

另外在 .jsx 中也可以使用 JSDoc,webstorm 也支持通过 JSDoc 实现类型提示, sublime 貌似还不支持。

在 VSCode 中会自动根据 JSDoc 的标注对变量、方法、方法参数等进行类型推断,通过 TypeScript 来进行智能提示,因此从编写注释开始学习 TypeScript 也是一个不错的选择,下面就来一一列举 JSDoc 在代码中的用法。

变量

@type 标注变量的类型

基础类型

/**
 * @type {number}
 */
let n;

/** @type {boolean} */
let flag;

/** @type {string} */
let str;

联合类型

如果一个变量可能是多种类型,则可以使用联合类型

/** 
 * @type {string | boolean}
 */
let x;

自定义类型

我们经常用到自定义类型,也就是 JavaScript 中的对象,对于简单的对象,可以用下面的写法

/**
 * @type {{name: string, age: number}}
 */
let user;

对于键值对比较多的复杂对象,可以使用 @typedef 来定义复杂类型,用 prop 或者 property 来定义对象的属性。

/**
 * @typedef {Object} goods
 * @property {string} name
 * @prop {number} code
 * @prop {string=} thumbnail 用 = 表示该属性是可能存在,也可能不存在
 * @prop {string} [introduction] 也可以给属性名加上 [] 表示这是一个可选属性
 * @prop {string[]} label
 */

 /**
  * @type {goods}
  */
 let phone;

数组

可以使用 [] 或者 Array 表示数组

/**
 * @type {number[]}
 */
let numList;

/**
 * @type {Array<string>}
 */
let strList;

对于已经定义的类型或者已经声明的变量,也是可以直接使用,下面分别声明一个 user 数组和 goods 数组

/**
 * @type {user[]}
 */
let userList;

/**
 * @type {goods[]}
 */
let goodsList;

如果不确定数组的每一项具体类型,可以使用 any * 或者交叉类型

/**
 * @type {any[]}
 */
let arr1;

/**
 * @type {*[]}
 */
let arr2;

/**
 * @type {(user | goods)[]}
 */
let arr3

泛型

/**
 * @template T
 * @param {T} p1
 * @return {T}
 */
function gen(p1) { return p1 }

函数

@name 表示函数的名称

@param 表示函数的参数

@return@returns 表示函数的返回值

一般函数的写法大致分为两种:声明式函数和函数表达式。

函数表达式

/**
 * @type {function (number, number): number}
 */
var getSum = (n1, n2) => n1 + n2;

声明式函数

/**
 * @name fn
 * @param {string} str
 * @param {boolean} flag
 * @returns {*[]}
 */
function fn(str, flag) {
  return [];
}

通过上面的注释写法,便可以在函数 fn 内部正确的识别出两个参数的类型,并且可以知道该函数返回值类型为数组。

对于函数参数的类型,写法和上面的变量写法一致,区别是将 @type 换成了 @param,函数的返回值也是同样的道理。

对象的方法

对函数的注释同样适用于对象的方法

var o = {
  /**
   * @param {string} msg
   * @returns {void}
   */
  say(msg) {
    console.log(msg);
  }
}

内置类型和其它类型

上面的例子只是简单的用到了一些常见的类型,然而在实际开发中,我们用到的不止这些,比如开始文章开头的例子中,有用到了 DOM 对象,那该怎么编写注释呢?其实 VSCode 已经为我们提供了很多的类型了,比如 DOM 对象对应的类型是 HTMLElement , 事件对象对应的类型是 Event,同时 DOM 对象还可以更细化,比如 HTMLCanvasElementHTMLImageElement 等等。

同时,我们在开发中也会用到第三方的类库或框架,通常情况下,这些类库都会有一份以 d.ts 结尾的声明文件,该声明文件中包含了所用到类型的所有提示,以最为经典的 jQuery 为例,如果在时在 webpack 环境下,在通过 npm 安装 jQuery 后,需要再单独安装对应的声明文件 @types/jquery ,这样 VSCode 就可以正确的识别 $ 符号,也可以在 JSDoc 中使用 JQuery, JQueryStatic 等这都类型了,就像下面这样

/**
 * @type {JQuery}
 */
var $btn = $('button');

/**
 * @param {number} userId
 * @returns {JQuery.jqXHR} 
 */
function getUser(userId) {
  return $.get(`/user/${userId}`);
}

大部分情况下,通过 npm 发布的包,都会包含其对应的声明文件,如果没有的话,可以通过这个地址 TypeSearch 来搜索一下并安装 ,如果感兴趣可以到这个仓库 DefinitelyTyped 看看。当然你也可以提供一些仓库内目前还没有声明文件,别人会非常感谢你的!

当然并不是所有的项目都用到了 npm ,仍有很多项目在使用 script 这种方式从 cdn 来引入 .js 文件,这种情况下用不到 webpack ,也用不到 npm ,那这个时候就要从上面所提到的仓库地址 DefinitelyTyped 来下载对应的声明文件了,然后通过 /// <reference path="" /> 这种形式来引入声明文件,就像下面这样

/// <reference path="./node_modules/@types/jquery/index.d.ts"/>

个人建议:即使是通过 cdn 方式来引入 .js 文件,也可以通过 npm 来安装 @types/ ,这样和在每个文件中通过 /// <reference path="" /> 引入声明文件相比,还是方便很多的。

总结

以上便是关于利用 JSDoc 实现 JavaScript 的类型提示。当然还有一些更深入的用法,比如全局模板文件,命名空间等,但是这些和 TypeScript 关系更大一些。当有一天你发现 JSDoc 已经不能满足你的时候,便是向着 TypeScript 大举进攻的时候了。

0

OpenResty 不完全指南

OpenResty 简介

OpenResty® 是一个基于 Nginx 与 Lua 的高性能 Web 平台。我们知道开发 Nginx 的模块需要用 C 语言,同时还要熟悉它的源码,成本和门槛比较高。国人章亦春把 LuaJIT VM 嵌入到了 Nginx 中,使得可以直接通过 Lua 脚本在 Nginx 上进行编程,同时还提供了大量的类库(如:lua-resty-mysql lua-resty-redis 等),直接把一个 Nginx 这个 Web Server 扩展成了一个 Web 框架,借助于 Nginx 的高性能,能够快速地构造出一个足以胜任 10K 乃至 1000K 以上单机并发连接的高性能 Web 应用系统。

Nginx 采用的是 master-worker 模型,一个 master 进程管理多个 worker 进程,worker 真正负责对客户端的请求处理,master 仅负责一些全局初始化,以及对 worker 进行管理。在 OpenResty 中,每个 worker 中有一个 Lua VM,当一个请求被分配到 worker 时,worker 中的 Lua VM 里创建一个 coroutine(协程) 来负责处理。协程之间的数据隔离,每个协程具有独立的全局变量 _G

ngx_lua works.png

OpenResty 处理请求流程

由于 Nginx 把一个请求分成了很多阶段,第三方模块就可以根据自己的行为,挂载到不同阶段处理达到目的。OpenResty 也应用了同样的特性。不同的阶段,有不同的处理行为,这是 OpenResty 的一大特色。OpenResty 处理一个请求的流程参考下图(从 Request start 开始):

image

指令 使用范围 解释
int_by_lua* init_worker_by_lua* http 初始化全局配置/预加载Lua模块
set_by_lua* server,server if,location,location if 设置nginx变量,此处是阻塞的,Lua代码要做到非常快
rewrite_by_lua* http,server,location,location if rewrite阶段处理,可以实现复杂的转发/重定向逻辑
access_by_lua* http,server,location,location if 请求访问阶段处理,用于访问控制
content_by_lua* location, location if 内容处理器,接收请求处理并输出响应
header_filter_by_lua* http,server,location,location if 设置 heade 和 cookie
body_filter_by_lua* http,server,location,location if 对响应数据进行过滤,比如截断、替换
log_by_lua http,server,location,location if log阶段处理,比如记录访问量/统计平均响应时间

更多详情请参考官方文档

配置 OpenResty

OpenResty 的 Lua 代码是提现在 nginx.conf 的配置文件之中的,可以与配置文件写在一起,也可以把 Lua 脚本放在一个文件中进行加载:

内联在 nginx.conf 中:

server {
    ...
    location /lua_content {
         # MIME type determined by default_type:
         default_type 'text/plain';

         content_by_lua_block {
             ngx.say('Hello,world!')
         }
    }
    ....
}    

通过加载 lua 脚本的方式:

server {
    ...
    location = /mixed {
         rewrite_by_lua_file /path/to/rewrite.lua;
         access_by_lua_file /path/to/access.lua;
         content_by_lua_file /path/to/content.lua;
     }
    ....
} 

OpenResty 变量的共享范围

全局变量

在 OpenResty 中,只有在 init_by_lua*init_worker_by_lua* 阶段才能定义真正的全局变量。因为在其他阶段,OpenResty 会设置一个隔离的全局变量表,以免在处理过程中污染了其他请求。即使在上述两个阶段可以定义全局变量,也尽量避免这么做。全局变量能解决的问题,用模块变量也能解决,而且会更清晰,干净。

模块变量

这里将定义在 Lua 模块中的变量称为模块变量。Lua VM 会将 require 进来的模块换成到 package.loaded table 里,模块里的变量都会被缓存起来,在同一个 Lua VM下,模块中的变量在每个请求中是共享的,这样就可以避免使用全局变量来实现共享了,看下面一个例子:

nginx.conf

worker_processes  1;

...
location {
    ...
    lua_code_cache on;
    default_type "text/html";
    content_by_lua_file 'lua/test_module_1.lua'
}

lua/test_module_1.lua

local module1 = require("module1")

module1.hello()

lua/module1.lua

local count = 0
local function hello() 
    count = count + 1
    ngx.say("count: ", count)
end

local _M  = {
    hello = hello
}   

return _M

当通过浏览器访问时,可以看到 count 输出是一个递增的,这也说明了在 lua/module1.lua 的模块变量在每个请求中时共享的:

count: 1
count: 2
.....

另外,如果 worker_processes 的数量大于 1 时呢,得到的结果可能就不一样了。因为每个 worker 中都有一个 Lua VM 了,模块变量仅在同一个 VM 下,所有的请求共享。如果要在多个 Worker 进程间共享请考虑使用 ngx.shared.DICT 或如 Redis 存储了。

本地变量

跟全局变量,模块变量相对,我们这里姑且把 *_by_lua* 里定义的变量称为本地变量。本地变量仅在当前阶段有效,如果需要跨阶段使用,需要借助 ngx.ctx 或者附加到模块变量里。

这里我们使用了 ngx.ctx 表在三个不同的阶段来传递使用变量 foo

location /test {
     rewrite_by_lua_block {
         ngx.ctx.foo = 76
     }
     access_by_lua_block {
         ngx.ctx.foo = ngx.ctx.foo + 3
     }
     content_by_lua_block {
         ngx.say(ngx.ctx.foo)
     }
 }

额外注意,每个请求,包括子请求,都有一份自己的 ngx.ctx 表。例如:

 location /sub {
     content_by_lua_block {
         ngx.say("sub pre: ", ngx.ctx.blah)
         ngx.ctx.blah = 32
         ngx.say("sub post: ", ngx.ctx.blah)
     }
 }

 location /main {
     content_by_lua_block {
         ngx.ctx.blah = 73
         ngx.say("main pre: ", ngx.ctx.blah)
         local res = ngx.location.capture("/sub")
         ngx.print(res.body)
         ngx.say("main post: ", ngx.ctx.blah)
     }
 }

访问 GET /main 输出:

main pre: 73
sub pre: nil  # 子请求中并没有获取到父请求的变量 $pre
sub post: 32
main post: 73

性能开关 lua_code_cache

开启或关闭在 *_by_lua_file(如:set_by_lua_file, content_by_lua_file) 指令中以及 Lua 模块中 Lua 代码的缓存。

若关闭,ngx_lua 会为每个请求创建一个独立的 Lua VM,所有 *_by_lua_file 指令中的代码将不会被缓存到内存中,并且所有的 Lua 模块每次都会从头重新加载。在开发模式下,这给我们带来了不需要 reload nginx 就能调试的便利性,但是在生成环境下,强烈建议开启。 若关闭,即使是一个简单的 Hello World 都会慢上一个数量级(每次 IO 读取和编译消耗很大)。

但是,那些直接写在 nginx.conf 配置文件中的 *_by_lua_block 指令下的代码不会在你编辑下实时更新,只有发送 HUP 信号给 Nginx 才能能够重新。

小案例

通过 OpenResty + Redis 实现动态路由

Nginx 经常用来作为反向代理服务器。通常情况下,我们将后端的服务配置在 Nginx 的 upstream 中,当后端服务有变更时就去修改 upstream 中的配置再通过 reload 的方式使其生效。这个操作如果在后端服务经常发生变更的情况下,操作起来就会显得有些繁琐了。现在利用 Lua + Redis 的方式将 upstream 中的配置放在 Redis 中,以实现动态配置的效果。

架构图

image

原理:

在求请求访问阶段处理(access_by_lua*)通过指定的规则(这个规则根据自己的需求去设计)从 Redis 中去获取相对应的后端服务地址去替换 Nginx 配置中的 proxy_pass 的地址。

流程:

  1. 在 Nginx 配置中创建后端服务地址的变量 $backend_server
    server {
        listen 80;
        server_name app1.example.com;

        location / {
            ...
            set $backend_server '';
        }
    }

同时在 Redis 中存入后端服务的地址。

set app1 10.10.10.10:8080
  1. 使用 ngx_redis2 模块来实现一个读取 Redis 的接口。
    # GET /get?key=some_key
    location = /get {
        internal;                        # 保护这个接口只运行内部调用
        set_unescape_uri $key $arg_key;  # this requires ngx_set_misc
        redis2_query get $key;
        redis2_pass foo.com:6379;        # redis_server and port
    }
  1. 在求请求访问阶段处理利用 ngx.location.capture 模块请求去上个阶段定义的 Redis 接口,并将结果替换 $backend_server
    location / {
        ...
        access_by_lua_block {
            local rds_key = "app1"
            # 从 redis 中获取 key 为 app1 对应的 server_ip
            local res = ngx.location.capture('/get', { args = {key = rds_key}})
            # 解析 redis 结果
            local parser = require("redis.parser")
            local server, typ = parser.parse_reply(res.body)
            if typ ~= parser.BULK_REPLY or not server then
                ngx.log(ngx.ERR, "bad redis response: ", res.body)
                ngx.exit(500)
            end

            ngx.var.backend_server = server
        }
    }
  1. Nginx 转发阶段将请求转发至后端服务。
    location / {
        ...
        access_by_lua_block {...};
        proxy_pass http://$backend_server;
    }

最后,推荐两个基于 OpenResty 的比较实用的两个开源项目:

参考

1+