用 Python 写一个 NoSQL 数据库

本文译自 https://jeffknupp.com/blog/2014/09/01/what-is-a-nosql-database-learn-by-writing-one-in-python/

  • 完整的示例代码已经放到了 GitHub 上, 请 点击这里, 这仅是一个极简的 demo, 旨在动手了解概念.
  • 如果对译文有任何的意见或建议,欢迎 提 issue 讨论, 批评指正.

未分类

NoSQL 这个词在近些年正变得随处可见. 但是到底 “NoSQL” 指的是什么? 它是如何并且为什么这么有用? 在本文, 我们将会通过纯 Python (我比较喜欢叫它, “轻结构化的伪代码”) 写一个 NoSQL 数据库来回答这些问题.

OldSQL

很多情况下, SQL 已经成为 “数据库” (database) 的一个同义词. 实际上, SQL 是 Strctured Query Language 的首字母缩写, 而并非指数据库技术本身. 更确切地说, 它所指的是从 RDBMS (关系型数据库管理系统, Relational Database Management System ) 中检索数据的一门语言. MySQL, MS SQL Server 和 Oracle 都属于 RDBMS 的其中一员.

RDBMS 中的 R, 即 “Relational” (有关系,关联的), 是其中内容最丰富的部分. 数据通过 表 (table) 进行组织, 每张表都是一些由 类型 (type) 相关联的 列 (column) 构成. 所有表, 列及其类的类型被称为数据库的 schema (架构或模式). schema 通过每张表的描述信息完整刻画了数据库的结构. 比如, 一张叫做 Car 的表可能有以下一些列:

  • Make: a string
  • Model: a string
  • Year: a four-digit number; alternatively, a date
  • Color: a string
  • VIN(Vehicle Identification Number): a string

在一张表中, 每个单一的条目叫做一 行 (row), 或者一条 记录 (record). 为了区分每条记录, 通常会定义一个 主键 (primary key). 表中的 主键 是其中一列 , 它能够唯一标识每一行. 在表 Car 中, VIN 是一个天然的主键选择, 因为它能够保证每辆车具有唯一的标识. 两个不同的行可能会在 Make, Model, Year 和 Color 列上有相同的值, 但是对于不同的车而言, 肯定会有不同的 VIN. 反之, 只要两行拥有同一个 VIN, 我们不必去检查其他列就可以认为这两行指的的就是同一辆车.

Querying

SQL 能够让我们通过对数据库进行 query (查询) 来获取有用的信息. 查询 简单来说, 查询就是用一个结构化语言向 RDBMS 提问, 并将其返回的行解释为问题的答案. 假设数据库表示了美国所有的注册车辆, 为了获取 所有的 记录, 我们可以通过在数据库上进行如下的 SQL 查询 :

SELECT Make, Model FROM Car;

将 SQL 大致翻译成中文:

  • “SELECT”: “向我展示”
  • “Make, Model”: “Make 和 Model 的值”
  • “FROM Car”: “对表 Car 中的每一行”

也就是, “向我展示表 Car 每一行中 Make 和 Model 的值”. 执行查询后, 我们将会得到一些查询的结果, 其中每个都是 Make 和 Model. 如果我们仅关心在 1994 年注册的车的颜色, 那么可以:

SELECT Color FROM Car WHERE Year = 1994;

此时, 我们会得到一个类似如下的列表:

Black
Red
Red
White
Blue
Black
White
Yellow

最后, 我们可以通过使用表的 (primary key) 主键 , 这里就是 VIN 来指定查询一辆车:

SELECT * FROM Car WHERE VIN = '2134AFGER245267'

上面这条查询语句会返回所指定车辆的属性信息.

主键被定义为唯一不可重复的. 也就是说, 带有某一指定 VIN 的车辆在表中至多只能出现一次. 这一点非常重要,为什么? 来看一个例子:

Relations

假设我们正在经营一个汽车修理的业务. 除了其他一些必要的事情, 我们还需要追踪一辆车的服务历史, 即在该辆车上所有的修整记录. 那么我们可能会创建包含以下一些列的 ServiceHistory 表:

未分类

这样, 每次当车辆维修以后, 我们就在表中添加新的一行, 并写入该次服务我们做了一些什么事情, 是哪位维修工, 花费多少和服务时间等.

但是等一下, 我们都知道,对于同一辆车而言,所有车辆自身信息有关的列是不变的。 也就是说,如果把我的 Black 2014 Lexus RX 350 修整 10 次的话, 那么即使 Make, Model, Year 和 Color 这些信息并不会改变,每一次仍然重复记录了这些信息. 与无效的重复记录相比, 一个更合理的做法是对此类信息只存储一次, 并在有需要的时候进行查询。

那么该怎么做呢? 我们可以创建第二张表: Vehicle , 它有如下一些列:

未分类

这样一来, 对于 ServiceHistory 表, 我们可以精简为如下一些列:

未分类

你可能会问,为什么 VIN 会在两张表中同时出现? 因为我们需要有一个方式来确认在 ServiceHistory 表的 这 辆车指的就是 Vehicle 表中的 那 辆车, 也就是需要确认两张表中的两条记录所表示的是同一辆车。 这样的话,我们仅需要为每辆车的自身信息存储一次即可. 每次当车辆过来维修的时候, 我们就在 ServiceHistory 表中创建新的一行, 而不必在 Vehicle 表中添加新的记录。 毕竟, 它们指的是同一辆车。

我们可以通过 SQL 查询语句来展开 Vehicle 与 ServiceHistory 两张表中包含的隐式关系:

SELECT Vehicle.Model, Vehicle.Year FROM Vehicle, ServiceHistory WHERE Vehicle.VIN = ServiceHistory.VIN AND ServiceHistory.Price > 75.00;

该查询旨在查找维修费用大于 $75.00 的所有车辆的 Model 和 Year. 注意到我们是通过匹配 Vehicle 与 ServiceHistory 表中的 VIN 值来筛选满足条件的记录. 返回的将是两张表中符合条件的一些记录, 而 “Vehicle.Model” 与 “Vehicle.Year” , 表示我们只想要 Vehicle 表中的这两列.

如果我们的数据库没有 索引 (indexes) (正确的应该是 indices), 上面的查询就需要执行 表扫描 (table scan) 来定位匹配查询要求的行。 table scan 是按照顺序对表中的每一行进行依次检查, 而这通常会非常的慢。 实际上, table scan 实际上是所有查询中最慢的。

可以通过对列加索引来避免扫描表。 我们可以把索引看做一种数据结构, 它能够通过预排序让我们在被索引的列上快速地找到一个指定的值 (或指定范围内的一些值). 也就是说, 如果我们在 Price 列上有一个索引, 那么就不需要一行一行地对整个表进行扫描来判断其价格是否大于 75.00, 而是只需要使用包含在索引中的信息 “跳” 到第一个价格高于 75.00 的那一行, 并返回随后的每一行(由于索引是有序的, 因此这些行的价格至少是 75.00)。

当应对大量的数据时, 索引是提高查询速度不可或缺的一个工具。当然, 跟所有的事情一样,有得必有失, 使用索引会导致一些额外的消耗: 索引的数据结构会消耗内存,而这些内存本可用于数据库中存储数据。这就需要我们权衡其利弊,寻求一个折中的办法, 但是为经常查询的列加索引是 非常 常见的做法。

The Clear Box

得益于数据库能够检查一张表的 schema (描述了每列包含了什么类型的数据), 像索引这样的高级特性才能够实现, 并且能够基于数据做出一个合理的决策。 也就是说, 对于一个数据库而言, 一张表其实是一个 “黑盒” (或者说透明的盒子) 的反义词?

当我们谈到 NoSQL 数据库的时候要牢牢记住这一点。 当涉及 query 不同类型数据库引擎的能力时, 这也是其中非常重要的一部分。

Schemas

我们已经知道, 一张表的 schema , 描述了列的名字及其所包含数据的类型。它还包括了其他一些信息, 比如哪些列可以为空, 哪些列不允许有重复值, 以及其他对表中列的所有限制信息。 在任意时刻一张表只能有一个 schema, 并且 表中的所有行必须遵守 schema 的规定 。

这是一个非常重要的约束条件。 假设你有一张数据库的表, 里面有数以百万计的消费者信息。 你的销售团队想要添加额外的一些信息 (比如, 用户的年龄), 以期提高他们邮件营销算法的准确度。 这就需要来 alter (更改) 现有的表 – 添加新的一列。 我们还需要决定是否表中的每一行都要求该列必须有一个值。 通常情况下, 让一个列有值是十分有道理的, 但是这么做的话可能会需要一些我们无法轻易获得的信息(比如数据库中每个用户的年龄)。因此在这个层面上,也需要有些权衡之策。

此外,对一个大型数据库做一些改变通常并不是一件小事。为了以防出现错误,有一个回滚方案非常重要。但即使是如此,一旦当 schema 做出改变后,我们也并不总是能够撤销这些变动。 schema 的维护可能是 DBA 工作中最困难的部分之一。

Key/Value Stores

在 “NoSQL” 这个词存在前, 像 memcached 这样的 键/值 数据存储 (Key/Value Data Stores) 无须 table schema 也可提供数据存储的功能。 实际上, 在 K/V 存储时, 根本没有 “表 (table)” 的概念。 只有 键 (keys) 与 值 (values) . 如果键值存储听起来比较熟悉的话, 那可能是因为这个概念的构建原则与 Python 的 dict 与 set 相一致: 使用 hash table (哈希表) 来提供基于键的快速数据查询。 一个基于 Python 的最原始的 NoSQL 数据库, 简单来说就是一个大的字典 (dictionary) .

为了理解它的工作原理,亲自动手写一个吧! 首先来看一下一些简单的设计想法:

  • 一个 Python 的 dict 作为主要的数据存储
  • 仅支持 string 类型作为键 (key)
  • 支持存储 integer, string 和 list
  • 一个使用 ASCLL string 的简单 TCP/IP 服务器用来传递消息
  • 一些像 INCREMENT, DELETE , APPEND 和 STATS 这样的高级命令 (command)

有一个基于 ASCII 的 TCP/IP 接口的数据存储有一个好处, 那就是我们使用简单的 telnet 程序即可与服务器进行交互, 并不需要特殊的客户端 (尽管这是一个非常好的练习并且只需要 15 行代码即可完成)。

对于我们发送到服务器及其它的返回信息,我们需要一个 “有线格式”。下面是一个简单的说明:

Commands Supported

  • PUT
    • 参数: Key, Value
    • 目的: 向数据库中插入一条新的条目 (entry)
  • GET
    • 参数: Key
    • 目的: 从数据库中检索一个已存储的值
  • PUTLIST
    • 参数: Key, Value
    • 目的: 向数据库中插入一个新的列表条目
  • APPEND
    • 参数: Key, Value
    • 目的: 向数据库中一个已有的列表添加一个新的元素
  • INCREMENT
    • 参数: key
    • 目的: 增长数据库的中一个整型值
  • DELETE
    • 参数: Key
    • 目的: 从数据库中删除一个条目
  • STATS
    • 参数: 无 (N/A)
    • 目的: 请求每个执行命令的 成功/失败 的统计信息

现在我们来定义消息的自身结构。

Message Structure

Request Messages

一条 请求消息 (Request Message) 包含了一个命令(command),一个键 (key), 一个值 (value), 一个值的类型(type). 后三个取决于消息类型,是可选项, 非必须。; 被用作是分隔符。即使并没有包含上述可选项, 但是在消息中仍然必须有三个 ; 字符。

COMMAND; [KEY]; [VALUE]; [VALUE TYPE]
  • COMMAND 是上面列表中的命令之一
  • KEY 是一个可以用作数据库 key 的 string (可选)
  • VALUE 是数据库中的一个 integer, list 或 string (可选)
    • list 可以被表示为一个用逗号分隔的一串 string, 比如说, “red, green, blue”
  • VALUE TYPE 描述了 VALUE 应该被解释为什么类型
    • 可能的类型值有:INT, STRING, LIST

Examples

  • “PUT; foo; 1; INT”

  • “GET; foo;;”

  • “PUTLIST; bar; a,b,c ; LIST”

  • “APPEND; bar; d; STRING”

  • “GETLIST; bar; ;”

  • STATS; ;;

  • INCREMENT; foo;;

  • DELETE; foo;;

Reponse Messages

一个 响应消息 (Reponse Message) 包含了两个部分, 通过 ; 进行分隔。第一个部分总是 True|False , 它取决于所执行的命令是否成功。 第二个部分是命令消息 (command message), 当出现错误时,便会显示错误信息。对于那些执行成功的命令,如果我们不想要默认的返回值(比如 PUT), 就会出现成功的信息。 如果我们返回成功命令的值 (比如 GET), 那么第二个部分就会是自身值。

Examples

  • True; Key [foo] set to [1]

  • True; 1

  • True; Key [bar] set to [[‘a’, ‘b’, ‘c’]]

  • True; Key [bar] had value [d] appended

  • True; [‘a’, ‘b’, ‘c’, ‘d’]

  • True; {‘PUTLIST’: {‘success’: 1, ‘error’: 0}, ‘STATS’: {‘success’: 0, ‘error’: 0}, ‘INCREMENT’: {‘success’: 0, ‘error’: 0}, ‘GET’: {‘success’: 0, ‘error’: 0}, ‘PUT’: {‘success’: 0, ‘error’: 0}, ‘GETLIST’: {‘success’: 1, ‘error’: 0}, ‘APPEND’: {‘success’: 1, ‘error’: 0}, ‘DELETE’: {‘success’: 0, ‘error’: 0}}

Show Me The Code!

我将会以块状摘要的形式来展示全部代码。 整个代码不过 180 行,读起来也不会花费很长时间。

Set Up

下面是我们服务器所需的一些样板代码:

"""NoSQL database written in Python"""

# Standard library imports
import socket

HOST = 'localhost'
PORT = 50505
SOCKET = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
STATS = {
    'PUT': {'success': 0, 'error': 0},
    'GET': {'success': 0, 'error': 0},
    'GETLIST': {'success': 0, 'error': 0},
    'PUTLIST': {'success': 0, 'error': 0},
    'INCREMENT': {'success': 0, 'error': 0},
    'APPEND': {'success': 0, 'error': 0},
    'DELETE': {'success': 0, 'error': 0},
    'STATS': {'success': 0, 'error': 0},
    }

很容易看到, 上面的只是一个包的导入和一些数据的初始化。

Set up(Cont’d)

接下来我会跳过一些代码, 以便能够继续展示上面准备部分剩余的代码。 注意它涉及到了一些尚不存在的一些函数, 不过没关系, 我们会在后面涉及。 在完整版(将会呈现在最后)中, 所有内容都会被有序编排。 这里是剩余的安装代码:

COMMAND_HANDERS = {
    'PUT': handle_put,
    'GET': handle_get,
    'GETLIST': handle_getlist,
    'PUTLIST': handle_putlist,
    'INCREMENT': handle_increment,
    'APPEND': handle_append,
    'DELETE': handle_delete,
    'STATS': handle_stats,
}

DATA = {}


def main():
    """Main entry point for script"""
    SOCKET.bind(HOST, PORT)
    SOCKET.listen(1)
    while 1:
        connection, address = SOCKET.accept()
        print('New connection from [{}]'.format(address))
        data = connection.recv(4096).decode()
        command, key, value = parse_message(data)
        if command == 'STATS':
            response = handle_stats()
        elif command in ('GET', 'GETLIST', 'INCREMENT', 'DELETE'):
            response = COMMAND_HANDERS[command](key)
        elif command in (
                'PUT',
                'PUTLIST',
                'APPEND', ):
            response = COMMAND_HANDERS[command](key, value)
        else:
            response = (False, 'Unknown command type {}'.format(command))
        update_stats(command, response[0])
        connection.sandall('{};{}'.format(response[0], response[1]))
        connection.close()


if __name__ == '__main__':
    main()

我们创建了 COMMAND_HANDLERS, 它常被称为是一个 查找表 (look-up table) . COMMAND_HANDLERS 的工作是将命令与用于处理该命令的函数进行关联起来。 比如说, 如果我们收到一个 GET 命令, COMMAND_HANDLERScommand 就等同于说 handle_get(key) . 记住,在 Python 中, 函数可以被认为是一个值,并且可以像其他任何值一样被存储在一个 dict 中。

在上面的代码中, 虽然有些命令请求的参数相同,但是我仍决定分开处理每个命令。 尽管可以简单粗暴地强制所有的 handle_ 函数接受一个 key 和一个 value , 但是我希望这些处理函数条理能够更加有条理, 更加容易测试,同时减少出现错误的可能性。

注意 socket 相关的代码已是十分极简。 虽然整个服务器基于 TCP/IP 通信, 但是并没有太多底层的网络交互代码。

最后还须需要注意的一小点: DATA 字典, 因为这个点并不十分重要, 因而你很可能会遗漏它。 DATA 就是实际用来存储的 key-value pair, 正是它们实际构成了我们的数据库。

Command Parser

下面来看一些 命令解析器 (command parser) , 它负责解释接收到的消息:

def parse_message(data):
    """Return a tuple containing the command, the key, and (optionally) the
    value cast to the appropriate type."""
    command, key, value, value_type = data.strip().split(';')
    if value_type:
        if value_type == 'LIST':
            value = value.split(',')
        elif value_type == 'INT':
            value = int(value)
        else:
            value = str(value)
    else:
        value = None
    return command, key, value

这里我们可以看到发生了类型转换 (type conversion). 如果希望值是一个 list, 我们可以通过对 string 调用 str.split(‘,’) 来得到我们想要的值。 对于 int, 我们可以简单地使用参数为 string 的 int() 即可。 对于字符串与 str() 也是同样的道理。

Command Handlers

下面是命令处理器 (command handler) 的代码. 它们都十分直观,易于理解。 注意到虽然有很多的错误检查, 但是也并不是面面俱到, 十分庞杂。 在你阅读的过程中,如果发现有任何错误请移步 这里 进行讨论.

def update_stats(command, success):
    """Update the STATS dict with info about if executing *command* was a
    *success*"""
    if success:
        STATS[command]['success'] += 1
    else:
        STATS[command]['error'] += 1


def handle_put(key, value):
    """Return a tuple containing True and the message to send back to the
    client."""
    DATA[key] = value
    return (True, 'key [{}] set to [{}]'.format(key, value))


def handle_get(key):
    """Return a tuple containing True if the key exists and the message to send
    back to the client"""
    if key not in DATA:
        return (False, 'Error: Key [{}] not found'.format(key))
    else:
        return (True, DATA[key])


def handle_putlist(key, value):
    """Return a tuple containing True if the command succeeded and the message
    to send back to the client."""
    return handle_put(key, value)


def handle_putlist(key, value):
    """Return a tuple containing True if the command succeeded and the message
    to send back to the client"""
    return handle_put(key, value)


def handle_getlist(key):
    """Return a tuple containing True if the key contained a list and the
    message to send back to the client."""
    return_value = exists, value = handle_get(key)
    if not exists:
        return return_value
    elif not isinstance(value, list):
        return (False, 'ERROR: Key [{}] contains non-list value ([{}])'.format(
            key, value))
    else:
        return return_value


def handle_increment(key):
    """Return a tuple containing True if the key's value could be incremented
    and the message to send back to the client."""
    return_value = exists, value = handle_get(key)
    if not exists:
        return return_value
    elif not isinstance(list_value, list):
        return (False, 'ERROR: Key [{}] contains non-list value ([{}])'.format(
            key, value))
    else:
        DATA[key].append(value)
        return (True, 'Key [{}] had value [{}] appended'.format(key, value))


def handle_delete(key):
    """Return a tuple containing True if the key could be deleted and the
    message to send back to the client."""
    if key not in DATA:
        return (
            False,
            'ERROR: Key [{}] not found and could not be deleted.'.format(key))
    else:
        del DATA[key]


def handle_stats():
    """Return a tuple containing True and the contents of the STATS dict."""
    return (True, str(STATS))

有两点需要注意: 多重赋值 (multiple assignment) 和代码重用. 有些函数仅仅是为了更加有逻辑性而对已有函数的简单包装而已, 比如 handle_get 和 handle_getlist . 由于我们有时仅仅是需要一个已有函数的返回值,而其他时候却需要检查该函数到底返回了什么内容, 这时候就会使用 多重赋值 。

来看一下 handle_append . 如果我们尝试调用 handle_get 但是 key 并不存在时, 那么我们简单地返回 handle_get 所返回的内容。 此外, 我们还希望能够将 handle_get 返回的 tuple 作为一个单独的返回值进行引用。 那么当 key 不存在的时候, 我们就可以简单地使用 return return_value .

如果它 确实存在 , 那么我们需要检查该返回值。并且, 我们也希望能够将 handle_get 的返回值作为单独的变量进行引用。 为了能够处理上述两种情况,同时考虑需要分开处理结果的情形,我们使用了多重赋值。 如此一来, 就不必书写多行代码, 同时能够保持代码清晰。 return_value = exists, list_value = handle_get(key) 能够显式地表明我们将要以至少两种不同的方式引用 handle_get 的返回值。

How Is This a Database?

上面的程序显然并非一个 RDBMS, 但却绝对称得上是一个 NoSQL 数据库。它如此易于创建的原因是我们并没有任何与 数据 (data) 的实际交互。 我们只是做了极简的类型检查,存储用户所发送的任何内容。 如果需要存储更加结构化的数据, 我们可能需要针对数据库创建一个 schema 用于存储和检索数据。

既然 NoSQL 数据库更容易写, 更容易维护,更容易实现, 那么我们为什么不是只使用 mongoDB 就好了? 当然是有原因的, 还是那句话,有得必有失, 我们需要在 NoSQL 数据库所提供的数据灵活性 (data flexibility) 基础上权衡数据库的可搜索性 (searchability).

Querying Data

假如我们上面的 NoSQL 数据库来存储早前的 Car 数据。 那么我们可能会使用 VIN 作为 key, 使用一个列表作为每列的值, 也就是说, 2134AFGER245267 = [‘Lexus’, ‘RX350’, 2013, Black] . 当然了, 我们已经丢掉了列表中每个索引的 涵义 (meaning) . 我们只需要知道在某个地方索引 1 存储了汽车的 Model , 索引 2 存储了 Year.

糟糕的事情来了, 当我们想要执行先前的查询语句时会发生什么? 找到 1994 年所有车的颜色将会变得噩梦一般。 我们必须遍历 DATA 中的 每一个值 来确认这个值是否存储了 car 数据亦或根本是其他不相关的数据, 比如说检查索引 2, 看索引 2 的值是否等于 1994,接着再继续取索引 3 的值. 这比 table scan 还要糟糕,因为它不仅要扫描每一行数据,还需要应用一些复杂的规则来回答查询。

NoSQL 数据库的作者当然也意识到了这些问题,(鉴于查询是一个非常有用的 feature) 他们也想出了一些方法来使得查询变得不那么 “遥不可及”。一个方法是结构化所使用的数据,比如 JSON, 允许引用其他行来表示关系。 同时, 大部分 NoSQL 数据库都有名字空间 (namespace) 的概念, 单一类型的数据可以被存储在数据库中该类型所独有的 “section” 中,这使得查询引擎能够利用所要查询数据的 “shape” 信息。

当然了,尽管为了增强可查询性已经存在 (并且实现了)了一些更加复杂的方法, 但是在存储更少量的 schema 与增强可查询性之间做出妥协始终是一个不可逃避的问题。 本例中我们的数据库仅支持通过 key 进行查询。 如果我们需要支持更加丰富的查询, 那么事情就会变得复杂的多了。

Summary

至此, 希望 “NoSQL” 这个概念已然十分清晰。 我们学习了一点 SQL, 并且了解了 RDBMS 是如何工作的。 我们看到了如何从一个 RDBMS 中检索数据 (使用 SQL 查询 (query)). 通过搭建了一个玩具级别的 NoSQL 数据库, 了解了在可查询性与简洁性之间面临的一些问题, 还讨论了一些数据库作者应对这些问题时所采用的一些方法。

即便是简单的 key-value 存储, 关于数据库的知识也是浩瀚无穷。虽然我们仅仅是探讨了其中的星星点点, 但是仍然希望你已经了解了 NoSQL 到底指的是什么, 它是如何工作的, 什么时候用比较好。如果您想要分享一些不错的想法, 欢迎 讨论.

CI/CD 之 GitLab CI

接着上篇文章整理,这篇文章主要介绍一下 GitLab CI 相关功能,并通过 GitLab CI 实现自动化构建项目;项目中所用的示例项目已经上传到了 GitHub

一、环境准备

首先需要有一台 GitLab 服务器,然后需要有个项目;这里示例项目以 Spring Boot 项目为例,然后最好有一台专门用来 Build 的机器,实际生产中如果 Build 任务不频繁可适当用一些业务机器进行 Build;本文示例所有组件将采用 Docker 启动, GitLab HA 等不在本文阐述范围内

  • Docker Version : 1.13.1
  • GitLab Version : 10.1.4-ce.0
  • GitLab Runner Version : 10.1.0
  • GitLab IP : 172.16.0.37
  • GitLab Runner IP : 172.16.0.36

二、GitLab CI 简介

GitLab CI 是 GitLab 默认集成的 CI 功能,GitLab CI 通过在项目内 .gitlab-ci.yaml 配置文件读取 CI 任务并进行相应处理;GitLab CI 通过其称为 GitLab Runner 的 Agent 端进行 build 操作;Runner 本身可以使用多种方式安装,比如使用 Docker 镜像启动等;Runner 在进行 build 操作时也可以选择多种 build 环境提供者;比如直接在 Runner 所在宿主机 build、通过新创建虚拟机(vmware、virtualbox)进行 build等;同时 Runner 支持 Docker 作为 build 提供者,即每次 build 新启动容器进行 build;GitLab CI 其大致架构如下
未分类

三、搭建 GitLab 服务器

3.1、GitLab 搭建

GitLab 搭建这里直接使用 docker compose 启动,compose 配置如下

version: '2'
services:
  gitlab:
    image: 'gitlab/gitlab-ce:10.1.4-ce.0'
    restart: always
    container_name: gitlab
    hostname: 'git.mritd.me'
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        external_url 'http:/git.mritd.me'
        # Add any other gitlab.rb configuration here, each on its own line
    ports:
      - '80:80'
      - '443:443'
      - '8022:22'
    volumes:
      - './data/gitlab/config:/etc/gitlab'
      - './data/gitlab/logs:/var/log/gitlab'
      - './data/gitlab/data:/var/opt/gitlab'

直接启动后,首次登陆需要设置初始密码如下,默认用户为 root
未分类
登陆成功后创建一个用户(该用户最好给予 Admin 权限,以后操作以该用户为例),并且创建一个测试 Group 和 Project,如下所示
未分类
未分类

3.2、增加示例项目

这里示例项目采用 Java 的 SpringBoot 项目,并采用 Gradle 构建,其他语言原理一样;如果不熟悉 Java 的没必要死磕此步配置,任意语言(最好 Java)整一个能用的 Web 项目就行,并不强求一定 Java 并且使用 Gradle 构建,以下只是一个样例项目;SpringBoot 可以采用 Spring Initializr 直接生成(依赖要加入 WEB),如下所示
未分类
将项目导入 IDEA,然后创建一个 index 示例页面,主要修改如下
– build.gradle

buildscript {
    ext {
        springBootVersion = '1.5.8.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.springframework.boot'

group = 'me.mritd'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8

repositories {
    mavenCentral()
}


dependencies {
    compile('org.springframework.boot:spring-boot-starter')
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.springframework.boot:spring-boot-starter-thymeleaf')
    testCompile('org.springframework.boot:spring-boot-starter-test')
}
  • 新建一个 HomeController
package me.mritd.TestProject;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

/*******************************************************************************
 * Copyright (c) 2005-2017 Mritd, Inc.
 * TestProject
 * me.mritd.TestProject
 * Created by mritd on 2017/11/24 下午12:23.
 * Description: 
 *******************************************************************************/
@Controller
public class HomeController {

    @RequestMapping("/")
    public String home(){
        return "index";
    }
}
  • templates 下新建 index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <title>Title</title>
</head>
<body>
<h1>Test...</h1>
</body>
</html>

最后项目整体结构如下
未分类
执行 assemble Task 打包出可执行 jar 包,并运行 java -jar TestProject-0.0.1-SNAPSHOT.jar 测试下能启动访问页面即可
未分类
最后将项目提交到 GitLab 后如下
未分类

四、GitLab CI 配置

针对这一章节创建基础镜像以及项目镜像,这里仅以 Java 项目为例;其他语言原理相通,按照其他语言对应的运行环境修改即可

4.1、增加 Runner

GitLab CI 在进行构建时会将任务下发给 Runner,让 Runner 去执行;所以先要添加一个 Runner,Runner 这里采用 Docker Compose 启动,build 方式也使用 Docker 方式 Build;compose 文件如下

version: '2'
services:
  gitlab-runner:
    container_name: gitlab-runner
    image: gitlab/gitlab-runner:alpine-v10.1.0
    restart: always
    network_mode: "host"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./config.toml:/etc/gitlab-runner/config.toml
    extra_hosts:
      - "git.mritd.me:172.16.0.37"

在启动前,我们需要先 touch 一下这个 config.toml 配置文件;该文件是 Runner 的运行配置,此后 Runner 所有配置都会写入这个文件(不 touch 出来 docker-compose 发现不存在会挂载一个目录进去,导致 Runner 启动失败);启动 docker-compose 后,需要进入容器执行注册,让 Runner 主动去连接 GitLab 服务器

# 生成 Runner 配置文件
touch config.toml
# 启动 Runner
docker-compose up -d
# 激活 Runner
docker exec -it gitlab-runner gitlab-runner register

在执行上一条激活命令后,会按照提示让你输入一些信息;首先输入 GitLab 地址,然后是 Runner Token,Runner Token 可以从 GitLab 设置中查看,如下所示
未分类
整体注册流程如下
未分类
注册完成后,在 GitLab Runner 设置中就可以看到刚刚注册的 Runner,如下所示
未分类
Runner 注册成功后会将配置写入到 config.toml 配置文件;由于两个测试宿主机都没有配置内网 DNS,所以为了保证 runner 在使用 docker build 时能正确的找到 GitLab 仓库地址,还需要增加一个 docker 的 host 映射( extra_hosts );同时为了能调用 宿主机 Docker 和持久化 build 的一些缓存还挂载了一些文件和目录;完整的 配置如下(配置文件可以做一些更高级的配置,具体参考 官方文档 )
– config.toml

concurrent = 1
check_interval = 0

[[runners]]
  name = "Test Runner"
  url = "http://git.mritd.me"
  token = "c279ec1ac08aec98c7141c7cf2d474"
  executor = "docker"
  builds_dir = "/gitlab/runner-builds"
  cache_dir = "/gitlab/runner-cache"
  [runners.docker]
    tls_verify = false
    image = "debian"
    privileged = false
    disable_cache = false
    shm_size = 0
    volumes = ["/data/gitlab-runner:/gitlab","/var/run/docker.sock:/var/run/docker.sock","/data/maven_repo:/data/repo","/data/maven_repo:/data/maven","/data/gradle:/data/gradle","/data/sonar_cache:/root/.sonar","/data/androidsdk:/usr/local/android","/data/node_modules:/data/node_modules"]
    extra_hosts = ["git.mritd.me:172.16.0.37"]
  [runners.cache]

注意,这里声明的 Volumes 会在每个运行的容器中都生效;也就是说 build 时新开启的每个容器都会被挂载这些目录;修改完成后重启 runner 容器即可,由于 runner 中没啥可保存的东西,所以可以直接 docker-compose down && docker-compose up -d 重启

4.2、创建基础镜像

由于示例项目是一个 Java 项目,而且是采用 Spring Boot 的,所以该项目想要运行起来只需要一个 java 环境即可,中间件已经被打包到了 jar 包中;以下是一个作为基础运行环境的 openjdk 镜像的 Dockerfile

FROM alpine:edge 

LABEL maintainer="mritd <[email protected]>"

ENV JAVA_HOME /usr/lib/jvm/java-1.8-openjdk
ENV PATH $PATH:/usr/lib/jvm/java-1.8-openjdk/jre/bin:/usr/lib/jvm/java-1.8-openjdk/bin

RUN apk add --update bash curl tar wget ca-certificates unzip 
        openjdk8 font-adobe-100dpi ttf-dejavu fontconfig 
    && rm -rf /var/cache/apk/* 

CMD ["bash"]

这个 openjdk Dockerfile 升级到了 8.151 版本,并且集成了一些字体相关的软件,以解决在 Java 中某些验证码库无法运行问题,详见 Alpine 3.6 OpenJDK 8 Bug;使用这个 Dockerfile,在当前目录执行 docker build -t mritd/openjdk:8 . build 一个 openjdk8 的基础镜像,然后将其推送到私服,或者 Docker Hub 即可

4.3、创建项目镜像

有了基本的 openjdk 的 docker 镜像后,针对于项目每次 build 都应该生成一个包含发布物的 docker 镜像,所以对于项目来说还需要一个项目本身的 Dockerfile;项目的 Dockerfile 有两种使用方式;一种是动态生成 Dockerfile,然后每次使用新生成的 Dockerfile 去 build;还有一种是写一个通用的 Dockerfile,build 时利用 ARG 参数传入变量;这里采用第二种方式,以下为一个可以反复使用的 Dockerfile

FROM mritd/openjdk:8-144-01

MAINTAINER mritd <[email protected]>

ARG PROJECT_BUILD_FINALNAME

ENV TZ 'Asia/Shanghai'
ENV PROJECT_BUILD_FINALNAME ${PROJECT_BUILD_FINALNAME}


COPY build/libs/${PROJECT_BUILD_FINALNAME}.jar /${PROJECT_BUILD_FINALNAME}.jar

CMD ["bash","-c","java -jar /${PROJECT_BUILD_FINALNAME}.jar"]

该 Dockerfile 通过声明一个 PROJECT_BUILD_FINALNAME 变量来表示项目的发布物名称;然后将其复制到根目录下,最终利用 java 执行这个 jar 包;所以每次 build 之前只要能拿到项目发布物的名称即可

4.4、Gradle 修改

上面已经创建了一个标准的通用型 Dockerfile,每次 build 镜像只要传入 PROJECT_BUILD_FINALNAME 这个最终发布物名称即可;对于发布物名称来说,最好不要固定死;当然不论是 Java 还是其他语言的项目我们都能将最终发布物变成一个固定名字,最不济可以写脚本重命名一下;但是不建议那么干,最好保留版本号信息,以便于异常情况下进入容器能够分辨;对于当前 Java 项目来说,想要拿到 PROJECT_BUILD_FINALNAME 很简单,我们只需要略微修改一下 Gradle 的 build 脚本,让其每次打包 jar 包时将项目的名称及版本号导出到文件中即可;同时这里也加入了镜像版本号的处理,Gradle 脚本修改如下
– build.gradle 最后面增加如下

bootRepackage {

    mainClass = 'me.mritd.TestProject.TestProjectApplication'
    executable = true

    doLast {
        File envFile = new File("build/tmp/PROJECT_ENV")

        println("Create ${archivesBaseName} ENV File ===> " + envFile.createNewFile())
        println("Export ${archivesBaseName} Build Version ===> ${version}")
        envFile.write("export PROJECT_BUILD_FINALNAME=${archivesBaseName}-${version}n")

        println("Generate Docker image tag...")
        envFile.append("export BUILD_DATE=`date +%Y%m%d%H%M%S`n")
        envFile.append("export IMAGE_NAME=mritd/test:`echo ${CI_BUILD_REF_NAME} | tr '/' '-'`-`echo ${CI_COMMIT_SHA} | cut -c1-8`-${BUILD_DATE}n")
        envFile.append("export LATEST_IMAGE_NAME=mritd/test:latestn")
    }
}

这一步操作实际上是修改了 bootRepackage 这个 Task(不了解 Gradle 或者不是 Java 项目的请忽略),在其结束后创建了一个叫 PROJECT_ENV 的文件,里面实际上就是写入了一些 bash 环境变量声明,以方便后面 source 一下这个文件拿到一些变量,然后用户 build 镜像使用,PROJECT_ENV 最终生成如下

export PROJECT_BUILD_FINALNAME=TestProject-0.0.1-SNAPSHOT
export BUILD_DATE=`date +%Y%m%d%H%M%S`
export IMAGE_NAME=mritd/test:`echo ${CI_BUILD_REF_NAME} | tr '/' '-'`-`echo ${CI_COMMIT_SHA} | cut -c1-8`-${BUILD_DATE}
export LATEST_IMAGE_NAME=mritd/test:latest

未分类

4.5、创建 CI 配置文件

一切准备就绪以后,就可以编写 CI 脚本了;GitLab 依靠读取项目根目录下的 .gitlab-ci.yml 文件来执行相应的 CI 操作;以下为测试项目的 .gitlab-ci.yml 配置

# 调试开启
#before_script:
#  - pwd
#  - env

cache:
  key: $CI_PROJECT_NAME/$CI_COMMIT_REF_NAME-$CI_COMMIT_SHA
  paths:
    - build

stages:
  - build
  - deploy

auto-build:
  image: mritd/build:2.1.1
  stage: build
  script:
    - gradle --no-daemon clean assemble
  tags:
    - test

deploy:
  image: mritd/docker-kubectl:v1.7.4
  stage: deploy
  script:
    - source build/tmp/PROJECT_ENV
    - echo "Build Docker Image ==> ${IMAGE_NAME}"
    - docker build -t ${IMAGE_NAME} --build-arg PROJECT_BUILD_FINALNAME=${PROJECT_BUILD_FINALNAME} .
#    - docker push ${IMAGE_NAME}
    - docker tag ${IMAGE_NAME} ${LATEST_IMAGE_NAME}
#    - docker push ${LATEST_IMAGE_NAME}
#    - docker rmi ${IMAGE_NAME} ${LATEST_IMAGE_NAME}
#    - kubectl --kubeconfig ${KUBE_CONFIG} set image deployment/test test=$IMAGE_NAME
  tags:
    - test
  only:
    - master
    - develop
    - /^chore.*$/

关于 CI 配置的一些简要说明如下

stages
stages 字段定义了整个 CI 一共有哪些阶段流程,以上的 CI 配置中,定义了该项目的 CI 总共分为 build、deploy 两个阶段;GitLab CI 会根据其顺序执行对应阶段下的所有任务;在正常生产环境流程可以定义很多个,比如可以有 test、publish,甚至可能有代码扫描的 sonar 阶段等;这些阶段没有任何限制,完全是自定义的,上面的阶段定义好后在 CI 中表现如下图
未分类

task
task 隶属于 stages 之下;也就是说一个阶段可以有多个任务,任务执行顺序默认不指定会并发执行;对于上面的 CI 配置来说 auto-build 和 deploy 都是 task,他们通过 stage: xxxx 这个标签来指定他们隶属于哪个 stage;当 Runner 使用 Docker 作为 build 提供者时,我们可以在 task 的 image 标签下声明该 task 要使用哪个镜像运行,不指定则默认为 Runner 注册时的镜像(这里是 debian);同时 task 还有一个 tags 的标签,该标签指明了这个任务将可以在哪些 Runner 上运行;这个标签可以从 Runner 页面看到,实际上就是 Runner 注册时输入的哪个 tag;对于某些特殊的项目,比如 IOS 项目,则必须在特定机器上执行,所以此时指定 tags 标签很有用,当 task 运行后如下图所示
未分类
除此之外 task 还能指定 only 标签用于限定那些分支才能触发这个 task,如果分支名字不满足则不会触发;默认情况下,这些 task 都是自动执行的,如果感觉某些任务太过危险,则可以通过增加 when: manual 改为手动执行;注意: 手动执行被 GitLab 认为是高权限的写操作,所以只有项目管理员才能手动运行一个 task,直白的说就是管理员才能点击;手动执行如下图所示
未分类

cache
cache 这个参数用于定义全局那些文件将被 cache;在 GitLab CI 中,跨 stage 是不能保存东西的;也就是说在第一步 build 的操作生成的 jar 包,到第二部打包 docker image 时就会被删除;GitLab 会保证每个 stage 中任务在执行时都将工作目录(Docker 容器 中)还原到跟 GitLab 代码仓库中一模一样,多余文件及变更都会被删除;正常情况下,第一步 build 生成 jar 包应当立即推送到 nexus 私服;但是这里测试没有搭建,所以只能放到本地;但是放到本地下一个 task 就会删除它,所以利用 cache 这个参数将 build 目录 cache 住,保证其跨 stage 也能存在

关于 .gitlab-ci.yml 具体配置更完整的请参考 官方文档

五、其他相关

5.1、GitLab 内置环境变量

上面已经基本搞定了一个项目的 CI,但是有些变量可能并未说清楚;比如在创建的 PROJECT_ENV 文件中引用了 ${CI_COMMIT_SHA} 变量;这种变量其实是 GitLab CI 的内置隐藏变量,这些变量在每次 CI 调用 Runner 运行某个任务时都会传递到对应的 Runner 的执行环境中;也就是说这些变量在每次的任务容器 SHELL 环境中都会存在,可以直接引用,具体的完整环境变量列表可以从 官方文档 中获取;如果想知道环境变量具体的值,实际上可以通过在任务执行前用 env 指令打印出来,如下所示
未分类
未分类

5.2、GitLab 自定义环境变量

在某些情况下,我们希望 CI 能自动的发布或者修改一些东西;比如将 jar 包上传到 nexus、将 docker 镜像 push 到私服;这些动作往往需要一个高权限或者说有可写入对应仓库权限的账户来支持,但是这些账户又不想写到项目的 CI 配置里;因为这样很不安全,谁都能看到;此时我们可以将这些敏感变量写入到 GitLab 自定义环境变量中,GitLab 会像对待内置变量一样将其传送到 Runner 端,以供我们使用;GitLab 中自定义的环境变量可以有两种,一种是项目级别的,只能够在当前项目使用,如下
未分类
另一种是组级别的,可以在整个组内的所有项目中使用,如下
未分类
这两种变量添加后都可以在 CI 的脚本中直接引用

5.3、Kubernetes 集成

对于 Kubernetes 集成实际上有两种方案,一种是对接 Kubernetes 的 api,纯代码实现;另一种取巧的方案是调用 kubectl 工具,用 kubectl 工具来实现滚动升级;这里采用后一种取巧的方式,将 kubectl 二进制文件封装到镜像中,然后在 deploy 阶段使用这个镜像直接部署就可以
未分类
其中 mritd/docker-kubectl:v1.7.4 这个镜像的 Dockerfile 如下

FROM docker:dind 

LABEL maintainer="mritd <[email protected]>"

ARG TZ="Asia/Shanghai"

ENV TZ ${TZ}

ENV KUBE_VERSION v1.8.0

RUN apk upgrade --update 
    && apk add bash tzdata wget ca-certificates 
    && wget https://storage.googleapis.com/kubernetes-release/release/${KUBE_VERSION}/bin/linux/amd64/kubectl -O /usr/local/bin/kubectl 
    && chmod +x /usr/local/bin/kubectl 
    && ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime 
    && echo ${TZ} > /etc/timezone 
    && rm -rf /var/cache/apk/*

CMD ["/bin/bash"]

这里面的 ${KUBE_CONFIG} 是一个自定义的环境变量,对于测试环境我将配置文件直接挂载入了容器中,然后 ${KUBE_CONFIG} 只是指定了一个配置文件位置,实际生产环境中可以选择将配置文件变成自定义环境变量使用

5.4、GitLab CI 总结

关于 GitLab CI 上面已经讲了很多,但是并不全面,也不算太细致;因为这东西说起来实际太多了,现在目测已经 1W 多字了;以下总结一下 GitLab CI 的总体思想,当思路清晰了以后,我想后面的只是查查文档自己试一试就行了

CS 架构
GitLab 作为 Server 端,控制 Runner 端执行一系列的 CI 任务;代码 clone 等无需关心,GitLab 会自动处理好一切;Runner 每次都会启动新的容器执行 CI 任务

容器即环境
在 Runner 使用 Docker build 的前提下;所有依赖切换、环境切换应当由切换不同镜像实现,即 build 那就使用 build 的镜像,deploy 就用带有 deploy 功能的镜像;通过不同镜像容器实现完整的环境隔离

CI即脚本
不同的 CI 任务实际上就是在使用不同镜像的容器中执行 SHELL 命令,自动化 CI 就是执行预先写好的一些小脚本

敏感信息走环境变量
一切重要的敏感信息,如账户密码等,不要写到 CI 配置中,直接放到 GitLab 的环境变量中;GitLab 会保证将其推送到远端 Runner 的 SHELL 变量中

基于 GitLab 的简单项目管理与协作流程

GitLab 是一个类似于 GitHub 的开源源码托管服务,它除了提供基于 git 的基本代码托管服务外。还具备很多与软件开发协作相关的其他功能。比如 issues、Merge Requests 等。

利用 GitLab 提供的这些功能,我们可以实践一些简单的项目管理和协作流程。这套流程借鉴于很多成功的开源项目,非常适合在小型开发团队里面使用。

使用 issues 来管理需求与缺陷

GitLab issues 类似于“工单系统”,是一个发布项目相关信息的地方。项目的所有成员都可以创建新的 issue,其他成员可以在 issue 下进行相关的讨论。

issues 本身是一个非常简单的功能,但是如果配合 “标签”、“里程碑” 等功能一起使用,就可以承担起一定的项目管理工作。

录入 issue

在项目的开发过程中,我们会碰到很多新的需求、软件 bug 等。这些需求与 bug ,就是 issue 最大的来源,它们都可以作为 issue 录入到项目的 issues 中。

因为 issue 的录入门槛很低,鼓励项目成员录入 issue 后,项目很容易就会出现大量的 issues。所以我们应该严格控制每个 issue 的内容质量,确保其他人可以通过这个 issue 获取足够多的信息,提高沟通效率。

不光是和需求和 bug,任何和项目有关的内容都可以录入到 issue 中。

编写优秀的“需求” issue
如果你要录入一个需求类的 issue,最好在内容主体中包含下面这些内容:

  • 用一句话描述你的需求,并用它作为标题
  • 这个需求是解决什么问题的?
  • 这个需求对软件现有功能会造成什么影响?
  • 这个需求应该实现什么样的功能?
  • 这个需求是否依赖其他模块提供相关支持?
  • [可选] 这个需求有哪些实现方式?
  • [可选] 这些可选的实现方式分别有哪些优缺点?

编写优秀的“bug” issue
如果你要录入一个 bug issue,最好在内容主体中包含下面这些内容:

  • 提供出现问题的软件版本号、操作系统环境等相关信息
  • 提供能够稳定复现问题的相关步骤
  • 描述期待行为与当前行为
  • [可选] 你对这个 bug 原因的相关分析

Review issue 并为其打上标签

当 issue 被创建后,应该等待项目的 owner (owner 指项目的所有者,是对项目各方面都比较了解的人,可以为多个人) 对 issue 进行 Review。

Review 时,如果 owner 觉得这个 issue 满足下面的任意条件:

  • 与项目本身的功能、市场定位有冲突
  • 与现存 issue 有重复
  • 其他不应该被保留的情况

则应该在评论中说明相关情况,并关闭该 issue。如果经过上面的过滤后,觉得 issue 应该被继续跟进,那就应该为它打上标签,方便之后的筛选、排期等工作。

“标签”是 issue 的核心特性,为了更好的使用它,我建议采用 “{type}/{value}” 这样的二维标签来取代传统的 “{value}” 单维标签,下面是一些常用的 issue 标签:

优先级:priority
优先级(priority)是最重要的标签之一。它直接影响 bug 需要被响应的速度、或需求的具体排期。

  • priority/P3:十分紧急
  • priority/P2:较为紧急
  • priority/P1:普通
  • priority/P0:不紧急

类型:kind
kind(类别)表示 issue 属于哪种类型。

  • kind/bug:软件缺陷
  • kind/feature:新功能
  • kind/enhancement:改进项,模块代码重构等不影响项目功能但是改善工程质量的 issue 可归入此类
  • kind/research:技术调研类,一般以输出某类结论或报告视为结束

工作量:size
size(工作量)表示 issue 需要大约花费多少时间/精力,可以用来做简单的工作量评估参考。

  • size/XL
  • size/M
  • size/SM

领域/模块:area
area用于标记当前 issue 属于项目中的什么领域/模块。这个分类下的具体标签由项目本身决定。比如 area/apiserver、area/controller 等。给 issue 打上 area 标签后,项目不同模块的相关负责人可以更方便的找到自己负责的相关 issues。

GitLab 的标签是一个非常灵活的功能,在具体使用中,不必拘泥于上面列出来的这几种标签,可以根据当前项目特点随意调整。

issue 的后续操作

当 issue 被创建、打上标签以后,就可以进行后续操作了。issue 的后续操作主要包括下面几种:

  • 认领 issue:每一个 issue 都有一个 Assignee(受理人),表示当前 issue 由谁在处理。在你准备开始具体的工作前,一定要记得将 issue 认领为自己所有。
  • 在 issue 下进行讨论:在 issue 下可以围绕 issue 进行讨论,在讨论过程中,可以通过 @USERNAME 的方式通知其他人关注当前 issue。

使用 issue 做项目里程碑管理

除了为 issue 打上标签以外,你还可以为 issue 绑定上 milestone(里程碑),来将 issue 与某些特定的项目节点关联起来。之后便可以在 milestones 页面查看每一个里程碑的进展。

和 labels 一样,里程碑也是一个十分灵活的功能,你可以根据项目需要建立不同的里程碑,比如:

  • 基于软件版本号:基于未来将要发布的版本号建立里程碑,比如 v1.0.3、v2.0.1 等等
  • 基于时间周期:基于特定的时间周期 – 比如敏捷开发中的一个 sprint – 来建立里程碑,比如 Y2017-M7W3、Y2017-M7W4 等等

使用 issue board

使用 issue board(类似于敏捷开发中的“看板”),可以在一个页面看到当前处于不同阶段的所有 issues。这个功能非常适合站立晨会时使用。

勤于关闭 issues

随着项目越来越大,项目累积的 issue 也会越来越多。而这些 issue 中有很多已经失去它的价值。

所以,为了避免有价值的 issue 淹没在这些过时的信息当中,我们应该定期 Review 现有的 issues,关闭掉那些已经过时的 issues。

基于 Merge Request 的开发流程

在 GitLab 上创建的项目,所有人都不应该直接往 master 分支推送代码。而是应该在其他分支(或者 fork 项目的分支)进行开发。并最终通过创建 Merge Request(类似 GitHub pull request)将代码合并到 master 分支。

创建 Merge Request 并进行 Code Review

基于 MR 的开发流程如下:

  • 开发者在自己的分支下进行开发,开发完成后,创建将该分支合并到 Master 的 Merge Request,改动进入 Review 状态
  • 进入 Review 状态的代码,将由团队内的其他一位成员(经验比较丰富、或者对该工作模块比较熟悉)对代码改动进行 Code Review
  • 大家对 Reivew 结果进行讨论,并提交新的修改
  • 最终达成一致后,代码被 Merge 进 Master 分支

灵活创建新分支来避免 MR 冲突

我们一般会用类似于 dev_piglei 这样的分支名称进行开发,遵循着 “开发” -> “push 并创建 MR” -> “开发” 这样的工作流程。

但是,因为一个分支是严格对应到一个 MR 的,当你在同一个分支上开发不同功能时,如果 MR 一直处于 open 状态,那这些不同功能都会被推送到同一个 MR 上,对 Review 过程产生困扰。

为了避免这种情况,最好为不同的功能项创建不同的分支并各自创建 MR,比如dev_feature_add_member、dev_feature_disabled_user。

在 git 工作流方面,git-flow workflow 是一个值得学习的内容

分拆大的 Merge Request

如果开发一些比较大的需求,我们通常会将他们一次实现完,然后作为一个大的 MR 来提交 Review。

但是如果每个 MR 过于复杂,会大大影响 Code Review 的效率。所以,如果你要实现一个比较复杂的特性,建议将它拆解为多个比较小的 MR 来依次提交。

假如,你要为网站的 feed 页面从零开始添加 redis 缓存功能。可能一开始想的提交这个大 MR:

  • 添加基于 redis 的缓存模块并为 feed 页面添加缓存并主动过期
    但这个 MR 里面包含了太多内容,会增加 Review 的难度。所以可以试着将这个功能拆解为下面三个更小的 MR:

  • 添加基于缓存模块

  • 为缓存模块添加 Redis 作为存储后端
  • 为 feed 页面提供缓存,并主动过期
    谨记:

  • 超过 1000 行的代码改动 Review 起来非常困难

  • 可以使用 feature flag(功能开关)在 PR 完全完成前屏蔽部分功能

Zabbix监控nginx进程

本文将介绍:

  • Nginx开启状态模板
  • 创建Nginx检测脚本
  • 图形化导入Nginx模块

此操作均在Zabbix3.2.6

Nginx开启状态模板(agent端)

yum install nginx -y

vi /etc/nginx/conf.d/default.conf

    location /nginx_status{
        stub_status on;
        access_log on;
    allow 127.0.0.1;
        allow 192.168.31.0/24;   只允许192.168.31.0这个网段访问
        deny all;
}
## deny all , 拒绝除 allow 中的主机之外所有主机访问此 URL ,实现过程中如果遇到 403 ,有可能是你把自己测试的机器拒绝了!


nginx -t 检查语法
nginx -s reload 重新加载配置文件  

查看nginx 状态信息:

[root@localhost ~]#  curl http://127.0.0.1/nginx_status
Active connections: 1 
server accepts handled requests
 3721 3721 3714 
Reading: 0 Writing: 1 Waiting: 0

注解:
Active connections: 1 当前活动的连接数

server accepts handled requests

3721 3721 3714

3721 从启动到现在一共处理的连接数

3721 从启动到现在成功创建的握手的次数

3714 总共处理的请求数(requests)
请求的丢失数=(握手-连接)
connection 连接数,tcp连接 
request http请求,GET/POST/DELETE

Reading: 0 Writing: 1 Waiting: 0 
Reading: 0 读取客户端Header的信息数 请求头
Writing: 1 返回给客户端的header的信息数 响应头 
Waiting: 0 等待的请求数

创建Nginx检测脚本

mkdir /usr/local/zabbix/scripts -p
vi /usr/local/zabbix/scripts/nginx_status.sh 

#!/bin/bash 
#Time:2017-11-19
#Author: bks.com

#HOST=`/sbin/ifconfig eth0 | sed -n '/inet /{s/.*addr://;s/ .*//;p}'` 
HOST="127.0.0.1" 
PORT="80" 

# Functions to return nginx stats 
# 检测nginx进程是否存在
function ping {
    /sbin/pidof nginx | wc -l
}
function active { 
/usr/bin/curl "http://$HOST:$PORT/nginx_status" 2>/dev/null| grep 'Active' | awk '{print $NF}' 
} 
function reading { 
/usr/bin/curl "http://$HOST:$PORT/nginx_status" 2>/dev/null| grep 'Reading' | awk '{print $2}' 
} 
function writing { 
/usr/bin/curl "http://$HOST:$PORT/nginx_status" 2>/dev/null| grep 'Writing' | awk '{print $4}' 
} 
function waiting { 
/usr/bin/curl "http://$HOST:$PORT/nginx_status" 2>/dev/null| grep 'Waiting' | awk '{print $6}' 
} 
function accepts { 
/usr/bin/curl "http://$HOST:$PORT/nginx_status" 2>/dev/null| awk NR==3 | awk '{print $1}' 
} 
function handled { 
/usr/bin/curl "http://$HOST:$PORT/nginx_status" 2>/dev/null| awk NR==3 | awk '{print $2}' 
} 
function requests { 
/usr/bin/curl "http://$HOST:$PORT/nginx_status" 2>/dev/null| awk NR==3 | awk '{print $3}' 
} 
# Run the requested function 
$1

# 对脚本赋予执行权限

chmod +x /usr/local/zabbix/scripts/nginx_status.sh 


编辑agent配置文件,定义Key

vim /etc/zabbix/zabbix_agentd.conf

UserParameter=nginx.status[*],/usr/local/zabbix/scripts/nginx_status.sh $1

# $1表示[*]这里面的参数,在此为"active",如果命令有$存在,那么在$前面在加上一个$


配置完后一定记得重启:
/etc/init.d/zabbix-agent restart

图形化导入Nginx模块

server端测试:

[root@CentOS7 ~]# zabbix_get -s 192.168.31.155 -p 10050 -k "nginx.status[active]"
1
#有数字信息返回则表示配置正常


图形化操作:

    配置-->模板-->导入   
模板下载:
zbx_export_templates.xml
http://pan.baidu.com/s/1pLFUJc3 密码:1234


为对应的主机导入nginx模板
    配置-->主机-->模板-->添加

yum部署zabbix监控

第1章 yum部署zabbix服务端

1.1 命令行部署

自己搭建的yum仓库(推荐-因为局限于网络-很坑)

[root@m01 ~]# tail -1 /etc/hosts
192.168.19.200 repo.zabbix.com mirrors.aliyun.com

获取yum源(只提供安装zabbix的源)

rpm -ivh http://repo.zabbix.com/zabbix/3.0/rhel/7/x86_64/zabbix-release-3.0-1.el7.noarch.rpm

安装zabbix,httpd,php(依赖中有httpd和php)

yum install zabbix-server-mysql zabbix-web-mysql

安装、启动 mariadb (mysql被oracle甲骨文公司收购)

yum -y install mariadb-server
systemctl start mariadb.service

创建数据库,权限

mysql
create database zabbix character set utf8 collate utf8_bin;
grant all privileges on zabbix.* to zabbix@localhost identified by 'zabbix';
flush privileges;
exit

导入数据库sql文件(安装server时下载的)

zcat /usr/share/doc/zabbix-server-mysql-3.0.13/create.sql.gz|mysql -uzabbix -pzabbix zabbix

配置zabbix Server连接mysql(yum安装时,大部分不需要修改)

sed -i.ori '115a DBPassword=zabbix' /etc/zabbix/zabbix_server.conf

修改apache-php配置 (yum安装时,大部分不需要修改)

sed -i.ori '18a php_value date.timezone  Asia/Shanghai' /etc/httpd/conf.d/zabbix.conf

启动 zabbix-server httpd

systemctl start zabbix-server
systemctl start httpd

=======至此,进入web界面进行操作=====

1.1.1 yum源配置-解释

推荐自己搭建yum仓库,安装会很顺利,不局限于网络yum源的各种安装不上的问题。(特别全的需要50G以上)
先准备yum源

[root@CentOS7 ~]# rpm -ql zabbix-release-3.0-1.el7.noarch
/etc/pki/rpm-gpg/RPM-GPG-KEY-ZABBIX
/etc/yum.repos.d/zabbix.repo
/usr/share/doc/zabbix-release-3.0
/usr/share/doc/zabbix-release-3.0/GPL

方法一:网上直接下载(网上安装)

rpm -ivh http://repo.zabbix.com/zabbix/3.0/rhel/7/x86_64/zabbix-release-3.0-1.el7.noarch.rpm

方法二:先自行下载此rpm包,再上传,最后安装(网上安装)
未分类

 [root@CentOS7 ~]# rpm -ivh zabbix-release-3.0-1.el7.noarch.rpm

方法三:自己搭建本地yum仓库,(推荐-下载速度更快)
未分类

1.1.2 MariaDB 与 mysql

MariaDB数据库管理系统是MySQL的一个分支,主要由开源社区在维护,采用GPL授权许可。开发这个分支的原因之一是:甲骨文公司收购了MySQL后,有将MySQL闭源的潜在风险,因此社区采用分支的方式来避开这个风险。
MariaDB的目的是完全兼容MySQL,包括API和命令行,使之能轻松成为MySQL的代替品。

1.2 zabbix-web界面设置

1.2.1 web图文过程详解

未分类
未分类
未分类
未分类
未分类

1.3 添加被监控主机

所有需要被监控的服务器都要执行
更新yum源(或者将此rpm包下载到本地再安装)

rpm -ivh http://repo.zabbix.com/zabbix/3.0/rhel/7/x86_64/zabbix-release-3.0-1.el7.noarch.rpm

安装zabbix-agent

yum install zabbix-agent

配置文件

sed -i.ori 's#Server=127.0.0.1#Server=172.16.1.61#' /etc/zabbix/zabbix_agentd.conf

启动

systemctl start zabbix-agent.service

1.3.1 zabbix-web界面操作

未分类

1.3.2 字符集优化

安装 “文泉驿-微米黑字体”

yum -y install wqy-microhei-fonts

替换原有字体

cp /usr/share/fonts/wqy-microhei/wqy-microhei.ttc /usr/share/fonts/dejavu/DejaVuSans.ttf

刷新web页面即可
未分类

Nginx 504报错,PHP-FPM无响应的问题

问题

测试环境,压测接口中间件时遇到报错Nginx 504,查询nginx日志

2017/11/21 15:20:15 [error] 26954#0: *1835 connect() failed (111: Connection refused) while connecting to upstream, client: 192.168.1.46, server: 192.168.23.95, request: "POST /screenInterface/FunctionByTime.php HTTP/1.1", upstream: "fastcgi://127.0.0.1:9000", host: "192.168.23.95:6010", referrer: "http://192.168.23.95:6010/screen2/"
2017/11/21 15:20:15 [error] 26954#0: *1821 connect() failed (111: Connection refused) while connecting to upstream, client: 192.168.1.46, server: 192.168.23.95, request: "POST /screenInterface/FUnctionByMobile.php HTTP/1.1", upstream: "fastcgi://127.0.0.1:9000", host: "192.168.23.95:6010", referrer: "http://192.168.23.95:6010/screen2/"
2017/11/21 15:20:15 [error] 26954#0: *1836 connect() failed (111: Connection refused) while connecting to upstream, client: 192.168.1.46, server: 192.168.23.95, request: "POST /screenInterface/LeftGraph.php HTTP/1.1", upstream: "fastcgi://127.0.0.1:9000", host: "192.168.23.95:6010", referrer: "http://192.168.23.95:6010/screen2/"

资料

分析与解决

初步推测为php-fpm无响应:

  • 能够成功访问nginx静态资源
  • 本地9000端口成功访问php资源php ./info.php
  • nginx error code 504

查询PHP-fpm的error日志发现报错:

[21-Nov-2017 12:28:30] WARNING: [pool www] server reached pm.max_children setting (5), consider raising it
[21-Nov-2017 12:33:29] WARNING: [pool www] server reached pm.max_children setting (5), consider raising it
[21-Nov-2017 14:10:57] WARNING: [pool www] server reached pm.max_children setting (5), consider raising it
[21-Nov-2017 14:31:05] WARNING: [pool www] server reached pm.max_children setting (5), consider raising it
[21-Nov-2017 14:57:54] ERROR: unable to bind listening socket for address '127.0.0.1:9000': Address already in use (98)
[21-Nov-2017 14:57:54] ERROR: FPM initialization failed
[21-Nov-2017 14:58:32] NOTICE: fpm is running, pid 26714
[21-Nov-2017 14:58:32] NOTICE: ready to handle connections
[21-Nov-2017 14:59:50] WARNING: [pool www] server reached pm.max_children setting (5), consider raising it
[21-Nov-2017 15:20:12] NOTICE: Terminating ...
[21-Nov-2017 15:20:12] NOTICE: exiting, bye-bye!
[21-Nov-2017 15:20:25] NOTICE: fpm is running, pid 26972
[21-Nov-2017 15:20:25] NOTICE: ready to handle connections
[21-Nov-2017 15:20:44] WARNING: [pool www] server reached pm.max_children setting (5), consider raising it

推测原因为pm.max_children设置过小,将增大该值后重启中间件,问题解决。

反思

Nginx 502 & Nginx 504

  • Nginx 502 Bad Gateway的含义是请求的PHP-CGI已经执行,但是由于某种原因(一般是读取资源的问题)没有执行完毕而导致PHP-CGI进程终止。
  • Nginx 504 Gateway Time-out的含义是所请求的网关没有请求到,简单来说就是没有请求到可以执行的PHP-CGI。

关于pm.max_children

一个前提设置: pm = static/dynamic,这个选项是标识fpm子进程的产生模式:

  • static :表示在fpm运行时直接fork出pm.max_chindren个worker进程,
  • dynamic:表示,运行时fork出start_servers个进程,随着负载的情况,动态的调整,最多不超过max_children个进程。

一般推荐用static,优点是不用动态的判断负载情况,提升性能,缺点是多占用些系统内存资源。

max_chindren代表的worker的进程数。对于配置越多能同时处理的并发也就越多,则是一个比较大的误区:

  • 管理进程和worker进程是通过pipe进行数据通讯的。所以进程多了,增加进程管理的开销,系统进程切换的开销,更核心的是,能并发执行的fpm进程不会超过cpu个数。因此通过多开worker的个数来提升qps是错误的。
  • 但worker进程开少了,如果server比较繁忙的话,会导到nginx把数据打到fpm的时候,发现所有的woker都在工作中,没有空闲的worker来接受请求,从而导致502。

如何配置max_children及优化PHP-FPM

php-fpm.conf有两个至关重要的参数:一个是”max_children”,另一个是”request_terminate_timeout”.

  • request_terminate_timeout的值可以根 据你服务器的性能进行设定。一般来说性能越好你可以设置越高,20分钟-30分钟都可以。由于服务器PHP脚本需要长时间运行,有的可能会超过10分钟因此我设置了900秒,这样不会导致PHP-CGI死掉而出现502 Bad gateway这个错误。
  • max_children的值原则上是越大越好,php-cgi的进程多了就会处理的很快,排队的请求就会很少。设置”max_children” 也需要根据服务器的性能进行设定,一般来说一台服务器正常情况下每一个php-cgi所耗费的内存在20M左右,因此”max_children”我设置成40个,20M*40=800M也就是说在峰值的时候所有PHP-CGI所耗内存在800M以内,低于我的有效内存1Gb。而如果我 的”max_children”设置的较小,比如5-10个,那么php-cgi就会“很累”,处理速度也很慢,等待的时间也较长。如果长时间没有得到处 理的请求就会出现504 Gateway Time-out这个错误,而正在处理的很累的那几个php-cgi如果遇到了问题就会出现502 Bad gateway这个错误。
  • max_requests:每个进程若超过这个数目(跟php进程有一点点关系,关系不大),就自动杀死。

Linux 配置 nginx、mysql、php-fpm、redis 开机启动

Linux(CentOS)上配置 nginx、mysql、php-fpm、redis 开机启动,编写开机启动脚本。

系统环境: CentOS Linux

I、nginx开机启动

1. 在/etc/init.d/目录下创建脚本

vim  /etc/init.d/nginx

2. 更改脚本权限

chmod 775 /etc/init.d/nginx

3. 编写脚本内容

#!/bin/bash
# nginx Startup script for the Nginx HTTP Server
# it is v.0.0.2 version.
# chkconfig: - 85 15
# description: Nginx is a high-performance web and proxy server.
#              It has a lot of features, but it's not for everyone.
# processname: nginx
# pidfile: /var/run/nginx.pid
# config: /usr/local/nginx/conf/nginx.conf
nginxd=/usr/local/webserver/nginx/sbin/nginx
nginx_config=/usr/local/webserver/nginx/conf/nginx.conf
nginx_pid=/usr/local/webserver/nginx/logs/nginx.pid
RETVAL=0
prog="nginx"
# Source function library.
.  /etc/rc.d/init.d/functions
# Source networking configuration.
.  /etc/sysconfig/network
# Check that networking is up.
[ ${NETWORKING} = "no" ] && exit 0
[ -x $nginxd ] || exit 0
# Start nginx daemons functions.
start() {
if [ -e $nginx_pid ];then
   echo "nginx already running...."
   exit 1
fi
   echo -n $"Starting $prog: "
   daemon $nginxd -c ${nginx_config}
   RETVAL=$?
   echo
   [ $RETVAL = 0 ] && touch /var/lock/subsys/nginx
   return $RETVAL
}
# Stop nginx daemons functions.
stop() {
        echo -n $"Stopping $prog: "
        killproc $nginxd
        RETVAL=$?
        echo
        [ $RETVAL = 0 ] && rm -f /var/lock/subsys/nginx /usr/local/webserver/nginx/logs/nginx.pid
}

reload() {
    echo -n $"Reloading $prog: "
    #kill -HUP `cat ${nginx_pid}`
    killproc $nginxd -HUP
    RETVAL=$?
    echo
}
# See how we were called.
case "$1" in
start)
        start
        ;;
stop)
        stop
        ;;
reload)
        reload
        ;;
restart)
        stop
        start
        ;;
status)
        status $prog
        RETVAL=$?
        ;;
*)
        echo $"Usage: $prog {start|stop|restart|reload|status|help}"
        exit 1
esac
exit $RETVAL

4. 设置开机启动

chkconfig nginxd on

II、设置mysql开机启动

将mysql安装目录下 support-files目录下的mysql.server文件拷贝到/etc/init.d/目录下并改名为mysqld,并更改权限

chmod 775 /etc/init.d/mysqld

设置开机启动

chkconfig mysqld on

III、php-fpm开机启动

1. 在/etc/init.d/目录下创建脚本

vim /etc/init.d/php-fpm

2. 更改脚本权限

chmod 775 /etc/init.d/php-fpm

3. 编写脚本内容

#!/bin/sh
#
# php-fpm - this script starts and stops the php-fpm daemin
#
# chkconfig: - 85 15
# processname: php-fpm
# config:      /usr/local/php/etc/php-fpm.conf

set -e

PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
DESC="php-fpm daemon"
NAME=php-fpm
DAEMON=/usr/local/php/sbin/$NAME     //这里设成自己的目录
CONFIGFILE=/usr/local/php/etc/php-fpm.conf   //这里设成自己的目录
PIDFILE=/usr/local/php/var/run/$NAME.pid   //这里设成自己的目录
SCRIPTNAME=/etc/init.d/$NAME   //这里设成自己的目录

# If the daemon file is not found, terminate the script.
test -x $DAEMON || exit 0

d_start(){
    $DAEMON -y $CONFIGFILE || echo -n " already running"
}

d_stop(){
    kill -QUIT `cat $PIDFILE` || echo -n " no running"
}

d_reload(){
    kill -HUP `cat $PIDFILE` || echo -n " could not reload"
}

case "$1" in
    start)
        echo -n "Starting $DESC: $NAME"
        d_start
        echo "."
        ;;
    stop)
        echo -n "Stopping $DESC: $NAME"
        d_stop
        echo "."
        ;;
    reload)
        echo -n "Reloading $DESC configuration..."
        d_reload
        echo "Reloaded."
        ;;
    restart)
        echo -n "Restarting $DESC: $NAME"
        d_stop
        # Sleep for two seconds before starting again, this should give the nginx daemon some time to perform a graceful stop
        sleep 2
        d_start
        echo "."
        ;;
    *)
        echo "Usage: $SCRIPTNAME {start|stop|restart|force-reload)" >&2
        exit 3
        ;;
esac
exit 0

4. 设置开机启动

chkconfig php-fpm on

Ⅳ、redis 开机启动

1. 在/etc/init.d/目录下创建脚本

vim /etc/init.d/redis

2. 更改脚本权限

chmod 775 /etc/init.d/redis

3. 编写脚本内容

###########################
PATH=/usr/local/bin:/sbin:/usr/bin:/bin

REDISPORT=6379
EXEC=/usr/local/bin/redis-server
REDIS_CLI=/usr/local/bin/redis-cli

PIDFILE=/var/run/redis.pid
CONF="/etc/redis.conf"

case "$1" in
    start)
        if [ -f $PIDFILE ]
        then
                echo "$PIDFILE exists, process is already running or crashed"
        else
                echo "Starting Redis server..."
                $EXEC $CONF
        fi
        if [ "$?"="0" ]
        then
              echo "Redis is running..."
        fi
        ;;
    stop)
        if [ ! -f $PIDFILE ]
        then
                echo "$PIDFILE does not exist, process is not running"
        else
                PID=$(cat $PIDFILE)
                echo "Stopping ..."
                $REDIS_CLI -p $REDISPORT SHUTDOWN
                while [ -x ${PIDFILE} ]
               do
                    echo "Waiting for Redis to shutdown ..."
                    sleep 1
                done
                echo "Redis stopped"
        fi
        ;;
   restart|force-reload)
        ${0} stop
        ${0} start
        ;;
  *)
    echo "Usage: /etc/init.d/redis {start|stop|restart|force-reload}" >&2
        exit 1
esac
##############################

4. 设置开机启动

chkconfig redis on

至此,大功告成。

可以用命令 chkconfig 查看开机启动服务列表

chkconfig --list

附录:

1、nigx重启错误

bind() to 0.0.0.0:80 failed (98: Address already in use)

这个是nginx重启是 经常遇到的。 网上找了很多信息 都是没有啥用。说的乱七八糟的。 发现原来是nginx重复重启。自己占用了端口。 解决方法

killall -9 nginx

杀掉nginx 进程 然后重启就行了。

service nginx restart

2、php-fpm 启动 关闭

php-fpm 不再支持 php-fpm 补丁具有的 /usr/local/php/sbin/php-fpm (start|stop|reload)等命令,需要使用信号控制:

master 进程可以理解以下信号

  • SIGINT, SIGTERM 立刻终止
  • SIGQUIT 平滑终止
  • SIGUSR1 重新打开日志文件
  • SIGUSR2 平滑重载所有worker进程并重新载入配置和二进制模块

示例:

php-fpm 关闭:

kill -SIGINT `cat /usr/local/php/var/run/php-fpm.pid`

php-fpm 重启:

kill -SIGUSR2 `cat /usr/local/php/var/run/php-fpm.pid`

其次配置文件不再使用的xml 格式,改为了INI,但是配置参数几乎和以前一样,可参照xml格式的格式配置。

3、nginx 启动 关闭

  • nginx的启动 (nginx.conf文件基本上位于nginx主目录中的conf目录中)
nginx -c nginx.conf
  • nginx的停止 (nginx.pid文件基本上位于nginx主目录中的logs目录中)
ps -ef | grep nginx

可发现数个nginx进程,其中标有master的为主进程,其它为子进程, 停止nginx主要就是对主进程进行信号控制.

从容停止

kill -QUIT `cat nginx.pid`

快速停止

kill -TERM `cat nginx.pid`

or

kill -INT `cat nginx.pid`

强制停止

kill -9 `cat nginx.pid`

nginx的平滑重启

首先要验证新的配置文件是否正确:

nginx -t -c nginx.conf

成功后向主进程发送HUP信号即可: [/shell]kill -HUP cat nginx.pid[/shell]

4、nginx 平滑升级

  1. 备份好旧的可执行文件,使用新版本替换旧版本

  2. kill -USR2 旧版本的主进程PID 进行平滑升级, 此时新老版本共存

  3. kill -WINCH 旧版本的主进程PID 逐步关闭旧主进程的工作进程

  4. 当旧主进程产生的工作进程全部关闭后, 可以决定是否使用新版本还是旧版本.(需要使用kill命令来杀死新或旧主进程)

#!/bin/sh
BASE_DIR='/usr/local/'
${BASE_DIR}nginx/sbin/nginx -t -c ${BASE_DIR}nginx/conf/nginx.conf >& ${BASE_DIR}nginx/logs/nginx.start
info=`cat ${BASE_DIR}nginx/logs/nginx.start`
if [ `echo $info | grep -c "syntax is ok" ` -eq 1 ]; then
if [ `ps aux|grep "nginx"|grep -c "master"` == 1 ]; then
kill -HUP `cat ${BASE_DIR}nginx/logs/nginx.pid`
echo "ok"
else
killall -9 nginx
sleep 1
${BASE_DIR}nginx/sbin/nginx
fi
else
echo "######## error: ########"
cat ${BASE_DIR}nginx/logs/nginx.start
fi

5、CentOS修改系统环境变量

我这里拿php作为一个例子,我的php安装在/usr/local/webserver/php下,没有把php加入环境变量时,你在命令行执行

# 查看当前php的版本信息
[root@CentOS ~]# php -v

会提示你此命令不存在。

下面详细说说linux下修改环境变量的方法

方法一:

在/etc/profile文件中添加变量【对所有用户生效(永久的)】
用VI在文件/etc/profile文件中增加变量,该变量将会对Linux下所有用户有效,并且是“永久的”。

[root@CentOS ~]# vim /etc/profile

在文件末尾加上如下两行代码

PATH=/usr/local/webserver/php/bin:$PATH
export PATH

如:

# /etc/profile

# System wide environment and startup programs, for login setup
# Functions and aliases go in /etc/bashrc

# It's NOT a good idea to change this file unless you know what you
# are doing. It's much better to create a custom.sh shell script in
# /etc/profile.d/ to make custom changes to your environment, as this
# will prevent the need for merging in future updates.

pathmunge () {
    case ":${PATH}:" in
        *:"$1":*)
            ;;
        *)
            if [ "$2" = "after" ] ; then
                PATH=$PATH:$1
            else
                PATH=$1:$PATH
            fi
    esac
}

if [ -x /usr/bin/id ]; then
    if [ -z "$EUID" ]; then
        # ksh workaround
        EUID=`id -u`
        UID=`id -ru`
    fi
    USER="`id -un`"
    LOGNAME=$USER
    MAIL="/var/spool/mail/$USER"
fi

# Path manipulation
if [ "$EUID" = "0" ]; then
    pathmunge /sbin
    pathmunge /usr/sbin
    pathmunge /usr/local/sbin
else
    pathmunge /usr/local/sbin after
    pathmunge /usr/sbin after
    pathmunge /sbin after
fi

HOSTNAME=`/bin/hostname 2>/dev/null`
HISTSIZE=1000
if [ "$HISTCONTROL" = "ignorespace" ] ; then
    export HISTCONTROL=ignoreboth
else
    export HISTCONTROL=ignoredups
fi

export PATH USER LOGNAME MAIL HOSTNAME HISTSIZE HISTCONTROL

# By default, we want umask to get set. This sets it for login shell
# Current threshold for system reserved uid/gids is 200
# You could check uidgid reservation validity in
# /usr/share/doc/setup-*/uidgid file
if [ $UID -gt 199 ] && [ "`id -gn`" = "`id -un`" ]; then
    umask 002
else
    umask 022
fi

for i in /etc/profile.d/*.sh ; do
    if [ -r "$i" ]; then
        if [ "${-#*i}" != "$-" ]; then
            . "$i"
        else
            . "$i" >/dev/null 2>&1
        fi
    fi
done

unset i
unset pathmunge

PATH=/usr/local/webserver/php/bin:$PATH
export PATH

要是刚才的修改马上生效,需要执行以下代码

[root@CentOS ~]# source /etc/profile

这时再查看系统环境变量,就能看见刚才加的东西已经生效了

[root@CentOS ~]# echo $PATH
/usr/local/webserver/php/bin:/usr/lib/qt-3.3/bin:/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin:/root/bin

现在就能直接使用php命令了(而不是像之前写很长一串/usr/local/webserver/php/bin/php -v),例如查看当前php的版本

[root@CentOS ~]# php -v
PHP 5.3.8 (cli) (built: Jun 27 2012 14:28:20)
Copyright (c) 1997-2011 The PHP Group
Zend Engine v2.3.0, Copyright (c) 1998-2011 Zend Technologies

方法二:

在用户目录下的.bash_profile文件中增加变量【对单一用户生效(永久的)】
用VI在用户目录下的.bash_profile文件中增加变量,改变量仅会对当前用户有效,并且是“永久的”。具体操作和方法1一样,这里就不在列举代码了。

方法三:

直接运行export命令定义变量【只对当前shell(BASH)有效(临时的)】

在shell的命令行下直接使用[export变量名=变量值]定义变量,该变量只在当前的shell(BASH)或其子shell(BASH)下是有效的,shell关闭了,变量也就失效了,再打开新shell时就没有这个变量,需要使用的话还需要重新定义。例如

export PATH=/usr/local/webserver/php/bin:$PATH

Linux下安装配置OpenResty,并测试在Nginx中使用Lua编程

一、简介

OpenResty,也被称为“ngx_openresty”,是一个以Nginx为核心同时包含很多第三方模块的Web应用服务器。借助于Nginx的事件驱动模型和非阻塞IO,可以实现高性能的Web应用程序。 OpenResty不是Nginx的分支,它只是一个软件包。主要有章亦春维护。

OpenResty默认集成了Lua开发环境,而且提供了大量组件如Mysql、Redis、Memcached等,使得在Nginx上开发Web应用更方便简单。

二、安装OpenResty

[root@hbase31 src]# wget https://openresty.org/download/openresty-1.11.2.5.tar.gz
[root@hbase31 src]# tar -zxvf openresty-1.13.6.1.tar.gz
[root@hbase31 openresty-1.13.6.1]# ./configure --prefix=/usr/local/openresty --user=www --group=www --with-http_stub_status_module --with-http_ssl_module --with-openssl=/usr/local/ssl --with-pcre=/usr/local/src/pcre-8.38 --add-module=/usr/local/src/ngx_cache_purge-2.3 --with-http_gzip_static_module --with-luajit
[root@hbase31 openresty-1.13.6.1]# make && make install

注:关于这里的编译参数可以认为是在Nginx的编译参数的基础上添加了其他组件的参数。如需查看更多参数可以使用以下命令:

[root@hbase31 openresty-1.13.6.1]# ./configure --help

配置nginx的启动脚本:

[root@hbase31 openresty-1.13.6.1]# vim /etc/init.d/nginx

添加如下内容:

#!/bin/bash
# nginx Startup script for the Nginx HTTP Server
# it is v.1.3.0 version.
# chkconfig: - 85 15
# description: Nginx is a high-performance web and proxy server.
#              It has a lot of features, but it's not for everyone.
# processname: nginx
# pidfile: /var/run/nginx.pid
# config: /usr/local/openresty/nginx/conf/nginx.conf
nginxd=/usr/local/openresty/nginx/sbin/nginx
nginx_config=/usr/local/openresty/nginx/conf/nginx.conf
nginx_pid=/usr/local/openresty/nginx/logs/nginx.pid
RETVAL=0
prog="nginx"
# Source function library.
.  /etc/rc.d/init.d/functions
# Source networking configuration.
.  /etc/sysconfig/network
# Check that networking is up.
[ ${NETWORKING} = "no" ] && exit 0
[ -x $nginxd ] || exit 0
# Start nginx daemons functions.
start() {
if [ -e $nginx_pid ];then
   echo "nginx already running...."
   exit 1
fi
   echo -n $"Starting $prog: "
   daemon $nginxd -c ${nginx_config}
   RETVAL=$?
   echo
   [ $RETVAL = 0 ] && touch /var/lock/subsys/nginx
   return $RETVAL
}
# Stop nginx daemons functions.
stop() {
        echo -n $"Stopping $prog: "
        killproc $nginxd
        RETVAL=$?
        echo
        [ $RETVAL = 0 ] && rm -f /var/lock/subsys/nginx $nginx_pid
}
reload() {
    echo -n $"Reloading $prog: "
    #kill -HUP `cat ${nginx_pid}`
    killproc $nginxd -HUP
    RETVAL=$?
    echo
}
# See how we were called.
case "$1" in
start)
        start
        ;;
stop)
        stop
        ;;
reload)
        reload
        ;;
restart)
        stop
        start
        ;;

status)
        status $prog
        RETVAL=$?
        ;;
*)
        echo $"Usage: $prog {start|stop|restart|reload|status|help}"
        exit 1
esac
exit $RETVAL

添加可执行权限:

[root@hbase31 openresty-1.13.6.1]# chmod a+x /etc/init.d/nginx

启动nginx:

[root@hbase31 openresty-1.13.6.1]# service nginx start

三、在Nginx中使用Lua脚本

[root@hbase31 vhost]# cd /usr/local/openresty/nginx/conf
[root@hbase31 conf]# mkdir lua vhost

(1)测试在Nginx中使用Lua脚本

[root@hbase31 nginx]# vim /usr/local/openresty/nginx/conf/vhost/lua.conf

其内容如下:

server {
    server_name localhost;
    listen 3000;
    index index.html index.htm index.jsp;

    location / {
        root /usr/local/openresty/nginx/html;
    }

    location /lua {
        default_type text/plain;
        content_by_lua 'ngx.say("hello,lua!")';
    } 

    limit_conn perip 1000;
    access_log logs/access_rua.log;
}

测试是否可以访问:

[root@hbase31 nginx]# service nginx reload

然后访问:http://192.168.1.31:3000/lua

如果输出以下内容则证明在Nginx中可以执行Lua脚本:

hello,lua!

(2)在Nginx中使用Lua脚本访问Redis

i)连接Redis集群,然后添加测试参数:

192.168.1.30:7000> set '123' '456'

ii)添加连接Redis的Lua脚本:

[root@hbase31 nginx]# vim /usr/local/openresty/nginx/conf/lua/redis.lua

其内容如下:

local redis = require "resty.redis"
local conn = redis.new()
conn.connect(conn, '192.168.1.30', '7000')
local res = conn:get("123")
if res==ngx.null then
    ngx.say("redis集群中不存在KEY——'123'")
    return
end
ngx.say(res)

iii)在上面的lua.conf配置文件中添加以下location:

    location /lua_redis {
        default_type text/plain;
        content_by_lua_file /usr/local/openresty/nginx/conf/lua/redis.lua;
    }
1
2
3
4
    location /lua_redis {
        default_type text/plain;
        content_by_lua_file /usr/local/openresty/nginx/conf/lua/redis.lua;
    }

iv)测试是否可以访问:

[root@hbase31 nginx]# service nginx reload

然后访问:http://192.168.1.31:3000/lua_redis

如果输出以下内容则证明可以访问redis:

456

Openresty最佳案例 | 第9篇:Openresty实现的网关权限控制

简介

采用openresty 开发出的api网关有很多,比如比较流行的kong、orange等。这些API 网关通过提供插件的形式,提供了非常多的功能。这些组件化的功能往往能够满足大部分的需求,如果要想达到特定场景的需求,可能需要二次开发,比如RBAC权限系统。本小节通过整合前面的知识点,来构建一个RBAC权限认证系统。

技术栈

本小节采用了以下的技术栈:

  • Openresty(lua+nginx)
  • mysql
  • redis
  • cjson

验证流程

  • 用户请求经过nginx,nginx的openresty的模块通过拦截请求来进行权限判断
  • openresty的access_by_lua_file模块,进行了一系列的判断
    • 用户的请求是否为白名单uri,如果为白名单uri,则直接通过验证, 进入下一个验证环节content_by_lua_file,这个环节直接打印一句话:“恭喜,请求通过。”
    • 如果用户请求不为白名单url,则需要取出请求header中的token,如果请求的header不存在token,则直接返回结果401,无权限访问。
      如果用户请求的uri的请求头包含token ,则取出token,解密token取出用户id
    • 根据取出的userid去查询数据库获取该用户的权限,如果权限包含了该请求的uri,请求可以通过,否则,请求不通过。
  • 请求如果通过access_by_lua_file模块,则进入到content_by_lua_file模块,该模块直接返回一个字符串给用户请求,在实际的开发中,可能为路由到具体的应用程序的服务器。

验证流程图如下所示:

未分类

vim /usr/example/example.conf ,加上以下的配置:

 location / {
    default_type "text/html";
    access_by_lua_file /usr/example/lua/api_access.lua;
    content_by_lua_file /usr/example/lua/api_content.lua;
  }

以上的配置表示,要不符合已有location路径的所有请求,将走这个location为/ 的路径。符合这个location的请求将进入 access_by_lua_file和 content_by_lua_file的模块判断。

vim /usr/example/lua/access_by_lua_file ,加上以下代码:

local tokentool = require "tokentool"
local mysqltool = require "mysqltool"

 function is_include(value, tab)
   for k,v in ipairs(tab) do
      if v == value then
           return true
       end
    end
    return false
 end

local white_uri={"/user/login","/user/validate"}

--local user_id = ngx.req.get_uri_args()["userId"]
--获取header的token值
local headers = ngx.req.get_headers() 
local token=headers["token"]
local url=ngx.var.uri
if ( not token) or (token==null) or (token ==ngx.null) then
  if is_include(url,white_uri)then

  else
    return ngx.exit(401)
  end  
else 
  ngx.log(ngx.ERR,"token:"..token)
  local user_id=tokentool.get_user_id(token)
  if (not user_id) or( user_id ==null) or ( user_id == ngx.null) then
      return ngx.exit(401)   
  end 

  ngx.log(ngx.ERR,"user_id"..user_id)
  local permissions={}
  permissions =tokentool.get_permissions(user_id)
  if(not permissions)or(permissions==null)or( permissions ==ngx.null) then
      permissions= mysqltool.select_user_permission(user_id)
      if permissions and permissions ~= ngx.null then
         tokentool.set_permissions(user_id,permissions)
      end
  end  
  if(not permissions)or(permissions==null)or( permissions ==ngx.null) then
     return ngx.exit(401)
  end 
  local is_contain_permission = is_include(url,permissions) 

  if is_contain_permission == true  then
     -- ngx.say("congratuation! you have pass the api gateway")
  else
      return ngx.exit(401) 
  end   
end

在上述代码中:

  • is_include(value, tab),该方法判断某个字符串在不在这个table中。
  • white_uri={“/user/login”,”/user/validate”} 是一个白名单的列表。
  • local headers = ngx.req.get_headers()从请求的uri的请求头获取token
  • is_include(url,white_uri)判断该url是否为白名单url
  • local user_id=tokentool.get_user_id(token)根据token获取该token对应的用户的user_id,在常见情况下,是根据token解析出user_id,但在不同的语言加密和加密token存在盐值不一样的情况,比较麻烦,所以我偷了个懒,直接存了redis,用户登录成功后存一下。
  • permissions =tokentool.get_permissions(user_id)根据user_id
    从redis获取该用户的权限。
  • permissions= mysqltool.select_user_permission(user_id)如果redis没有存该用户的权限,则从数据库读。
  • tokentool.set_permissions(user_id,permissions),将从数据库中读取的权限点存在reddis中。
  • local is_contain_permission = is_include(url,permissions),判断该url 在不在该用户对应的权限列表中。

如果所有的判断通过,则该用户请求的具有权限访问,则进入content_by_lua_file模块,直接在这个模块给请求返回“congratulations! you have passed the api gateway”。

vim /usr/example/lua/api_content.lua ,添加以下内容:

ngx.say("congratulations!"," you have passed ","the api gateway")  
----200状态码退出  
return ngx.exit(200)  

验证演示

打开浏览器访问http://116.196.177.123/user/login,浏览器显示:

congratulations! you have passed the api gateway

/user/login这个url 在白名单的范围内,所以它是可以通过权限验证的。

打开浏览器访问http://116.196.177.123/user/sss,显示以下内容:

401 Authorization Required

openresty/1.11.2.4

在redis中添加一对key-value,key为token_forezp,value为1,即token_forezp对应的用户的id为1.

/usr/servers/redis-3.2.6

src/redis-cli

set token_forezp 1

初始化以下的sql脚本,即给用户id为1的用户关联角色,角色并关联权限:

INSERT INTO `permission` VALUES ('1', '/user/orgs');
INSERT INTO `role` VALUES ('1', 'user');
INSERT INTO `role_permission` VALUES ('1', '1', '1');
INSERT INTO `user` VALUES ('1', 'forezp');
INSERT INTO `user_role` VALUES ('1', '1', '1');

用postman请求,在请求头中加入token,值为token_forezp,请求结果如下:

未分类

Openresty最佳案例 | 第8篇:RBAC介绍、sql和redis模块工具类

RBAC介绍

RBAC(Role-Based Access Control,基于角色的访问控制),用户基于角色的访问权限控制。简单地说,一个用户拥有若干角色,每一个角色拥有若干权限。这样,就构造成“用户-角色-权限”的授权模型。在这种模型中,用户与角色之间,角色与权限之间,一般都是多对多的关系。如图所示:

未分类

sql_tool

在本案例中,采用的就是这种权限设计的方式。具体的sql语句脚本如下:

CREATE TABLE `user` (
`id`  int(11) NOT NULL AUTO_INCREMENT ,
`name`  varchar(255) CHARACTER SET latin1 COLLATE latin1_swedish_ci NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=latin1 COLLATE=latin1_swedish_ci
AUTO_INCREMENT=2
ROW_FORMAT=COMPACT
;


CREATE TABLE role(
`id`  int(11) NOT NULL AUTO_INCREMENT ,
`name`  varchar(255) CHARACTER SET latin5 NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=latin1 COLLATE=latin1_swedish_ci
AUTO_INCREMENT=2
ROW_FORMAT=COMPACT
;

CREATE TABLE permission(
`id`  int(11) NOT NULL AUTO_INCREMENT ,
`permission`  varchar(255) CHARACTER SET latin1 COLLATE latin1_swedish_ci NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=latin1 COLLATE=latin1_swedish_ci
AUTO_INCREMENT=3
ROW_FORMAT=COMPACT
;

CREATE TABLE user_role(
`id`  int(11) NOT NULL AUTO_INCREMENT ,
`user_id`  int(11) NULL DEFAULT NULL ,
`role_id`  int(11) NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=latin1 COLLATE=latin1_swedish_ci
AUTO_INCREMENT=2
ROW_FORMAT=COMPACT
;


CREATE TABLE role_permission(
`id`  int(11) NOT NULL AUTO_INCREMENT ,
`role_id`  int(11) NULL DEFAULT NULL ,
`permission_id`  int(11) NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=latin1 COLLATE=latin1_swedish_ci
AUTO_INCREMENT=3
ROW_FORMAT=COMPACT
;

初始化以下的sql脚本,即给用户id为1的用户关联角色,角色并关联权限:

INSERT INTO `permission` VALUES ('1', '/user/orgs');
INSERT INTO `role` VALUES ('1', 'user');
INSERT INTO `role_permission` VALUES ('1', '1', '1');
INSERT INTO `user` VALUES ('1', 'forezp');
INSERT INTO `user_role` VALUES ('1', '1', '1');

在本案例中,需要根据user表中的Id获取该Id对应的权限。首先根据userId获取该用户对应的角色,再根据根据该角色获取相应的权限,往往一个用户具有多个角色,而角色又有多个权限。比如查询userId为1 的用户的权限的sql语句如下:

SELECT  a.id,a.permission from permission a ,role_permission b,role c,user_role d,user e WHERE a.id=b.permission_id and c.id=b.role_id and d.role_id=c.id and d.user_id=e.id and e.id=1"

在Openresty中怎么连接数据库,怎么查询sql语句,在之前的文章已将讲述过了。根据用户id获取用户的权限的功能是一个使用率极高的功能,所以考虑将这个功能模块化。

vim /usr/example/lualib/sql_tool.lua ,编辑加入以下的代码:

local mysql = require("resty.mysql")  

local function close_db(db)  
    if not db then  
        return  
    end  
    db:close()  
end  

local function select_user_permission(user_id)

   local db, err = mysql:new()
   if not db then  
      ngx.say("new mysql error : ", err)  
      return  
   end 
   db:set_timeout(1000)  

   local props = {  
      host = "127.0.0.1",  
      port = 3306,  
      database = "test",  
      user = "root",  
      password = "123"  
   }

  local res, err, errno, sqlstate = db:connect(props)  

  if not res then  
     ngx.say("connect to mysql error : ", err, " , errno : ", errno, " , sqlstate : ", sqlstate)  
     close_db(db)
  end

  local select_sql = "SELECT  a.id,a.permission from permission a ,role_permission b,role c,user_role d,user e WHERE a.id=b.permission_id and c.id=b.role_id and d.role_id=c.id and d.user_id=e.id and e.id="..user_id
  res, err, errno, sqlstate = db:query(select_sql)  
  if not res then  
     ngx.say("select error : ", err, " , errno : ", errno, " , sqlstate : ", sqlstate)  
     return close_db(db)  
  end  

   local permissions={}
   for i, row in ipairs(res) do  
     for name, value in pairs(row) do
    if name == "permission" then
          table.insert(permissions, 1, value)
        end  

     end  
   end  
 return permissions 
end

local _M = {  
    select_user_permission= select_user_permission
}  

return _M  

在上面的代码中,有一个select_user_permission(user_id)方法,该方法根据用户名获取该用户的权限。查出来存在一个table 类型的 local permissions={}中。

vim /usr/example/example.conf 加上以下的代码:

location ~ /sql_tool{
  default_type 'text/html';
  content_by_lua_file /usr/example/lua/test_sql_tool.lua;
 }

在浏览器上访问http://116.196.177.123/sql_tool,浏览器显示如下的内容:

/user/orgs

tokentool

在之前的文章讲述了如何使用Openresty连接redis,并操作redis。 这小节将讲述如何使用openresty连接redis,并写几个方法,用于存储用户的token等,并将这些信息模块化,主要有以下几个方法:

  • close_redis(red) 通过连接池的方式释放一个连接
  • connect() 连接redis
  • has_token(token) redis中存在token 与否
  • get_user_id(token) 根据token获取用户id
  • set_permissions(user_id,permissions) 根据userid设置权限
  • get_permissions(user_id)根据userid获取权限

vim /usr/example/lualib/tokentool.lua 编辑一下内容:

module("tokentool", package.seeall)
local redis = require "resty.redis"
local str = require "resty.string"
local cjson = require("cjson")  


local redis_host = "127.0.0.1"
local redis_port = 6379

local function close_redis(red)  
    if not red then  
        return  
    end  
    local pool_max_idle_time = 10000 --毫秒  
    local pool_size = 100 --连接池大小  
    local ok, err = red:set_keepalive(pool_max_idle_time, pool_size)  
    if not ok then  
        ngx.say("set keepalive error : ", err)  
    end  
end 

local function connect()
    local red = redis:new()
    red:set_timeout(1000)
    local ok, err = red:connect(redis_host, redis_port)
    if not ok then
        return false
    end
    --local res, err = red:auth("xiaoantimes")
    --if not res then
     -- ngx.say("failed to authenticate: ", err)
     -- return false
    --end
    --ok, err = red:select(1)
    --if not ok then
      --  return false
    --end
    return red
end

function has_token(token)
    local red = connect()
    if red == false then
        return false
    end

    local res, err = red:get(token)
    if not res then
        return false
    end
    close_redis(red)  
    return true
end

function set_permissions(user_id,permissions)
  if (permissions==null) or( permissions==ngx.null) then
     return false
  end 
  local str = cjson.encode(permissions)  
  ngx.log(ngx.ERR,"set redis p:"..str)
  local red=connect()
  if red== false then
     return false
  end
  local ok, err = red:set(user_id,str)
  if not ok then
     return false
  end
  return true 
end

function get_permissions(user_id)
  local red=connect()
  if red== false then
     return false
  end
  local res, err = red:get(user_id)
  if (not res) or (res == ngx.null) then
     return
  end 
  ngx.log(ngx.ERR,"get redis p:"..res);
  local permissions=cjson.decode(res)  
  return permissions
end

function get_user_id(token)
    local red = connect()
    local resp, err = red:get(token)  
    if not resp then  
      ngx.say("get msg error : ", err)  
      return close_redis(red)  
    end  
    close_redis(red)  
    return resp
end

vim /usr/example/lua/test_token_tool.lua,加上以下的内容:

local tokentool= require "tokentool"
local ret = tokentool.has_token("msg")
ngx.log(ngx.ERR,ret)
if ret == true then
   ngx.say("ok")
else
   ngx.say("oops,error")
end

在/usr/example/example.conf加上以下的内容:

 location ~ /token_tool{
     default_type 'text/html';
     lua_code_cache on;
     content_by_lua_file /usr/example/lua/test_token_tool.lua;

 }

打开浏览器访问http://116.196.177.123/token_tool,浏览器显示:

ok