场景一:设备在线状态保持

需求

有若干台设备,设备具备定时向server发送heartbeat的功能,后台需要在设备列表实时监控设备的在线情况(允许有不大于5分钟的时间延迟)

实现

在openresty配置中加入用于处理心跳请求的location块,如下

location /device/heartbeat {
	default_type text/html;
	lua_need_request_body on;
	content_by_lua_file src/heartbeat_detector.lua;
}

lua脚本

-- 获取请求方式,只允许POST请求
local request_method = ngx.var.request_method

-- ngx.log(ngx.ERR, "headers:"..ngx.var.http_user_agent) 


if request_method ~= "POST" then
    ngx.exit(ngx.HTTP_NOT_ALLOWED)
end

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.log(ngx.ERR, "set redis keepalive error : ", err)
    end
end

-- 引入json处理库
local cjson = require "cjson"

-- 临时文件读取函数
function getFile(file_name)
    local f = assert(io.open(file_name, 'r'))
    local string = f:read("*all")
    f:close()
    return string
end

-- 获取请求体
ngx.req.read_body()

-- 获取请求body
local data = ngx.req.get_body_data()

-- 如果请求body为空,则从临时文件获取
if nil == data then
    local file_name = ngx.req.get_body_file()
    if file_name then
        data = getFile(file_name)
    end
end

-- 将data转换为object
local obj = cjson.decode(data)

-- 如果当前请求为心跳维持请求
if obj.Name == "KeepAlive" then
    --[[
    初始化redis
    ]]
    local redis = require "resty.redis"
    local red = redis:new()
    red:set_timeout(1000)
    local host = 'ip'
    local port = port
    local ok, err = red:connect(host,port)
    if not ok then
        return close_redis(red)
    end

    -- 请注意这里 auth 的调用过程
    local count
    ount, err = red:get_reused_times()
    if 0 == count then
        ok, err = red:auth("password")
        if not ok then
            ngx.say("failed to auth: ", err)
            return
        end
    elseif err then
        ngx.say("failed to get reused times: ", err)
        return
    end

    -- redis前缀
    local prefix = 'device_heartbeat:'

    -- 缓存心跳
    res, err = red:set(prefix..obj.DeviceId, 1, 'EX', 300)
end

需要保证在项目的已有路由中没有/device/heartbeat,此时/device/heartbeat的请求会交给lua脚本处理,相对于php性能消耗更少,php后台获取到数据后可直接在redis中查找该设备对应的心跳key是否存在来确认设备是否在线

场景二:小区访客单元开门密码

需求

当前项目的另外一个需求是:小区用户app具备访客密码生成/刷新功能,密码的过期时间是30分钟(使用被动过期实现),用户可将现有密码通过短信发送至访客手机,访客可通过密码进行小区单元门开门,同时对用户每天发送短信上限进行限制(5次),每次开门需要生成包含访客信息和被访人信息的记录

实现

使用两个key:value完成基本的实现,id_prefix_user_id:password,pwd_prefix_community_uuid_pwd:user_id
第一个key:用于app请求访客密码时判断是否需要重新生成,以及刷新密码时删除原有密码
第二个key:用于访客进行密码开锁时识别对应的用户,将访客和用户信息写入到访客记录
用户生成访客密码时拼接上小区的id(密码长度是4位,需要保证密码数量在10000以内,因此使用小区将用户分组减少数据量)

以下为laravel框架的代码实现(项目使用了jwt,二次封装了laravel原有的response()->json()方法为beJson())

<?php

namespace App\Http\Controllers;

use App\Traits\JsonResponse;
use App\Traits\SendSMS;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;

class UserController extends Controller
{
    use JsonResponse, SendSMS;

    private $auth;

    //用户访客开门id前缀
    const USER_VISITOR_ID_PREFIX = 'user_visitor_id:';

    //用户访客开门密码前缀
    const USER_VISITOR_PWD_PREFIX = 'user_visitor_pwd:';

    //短信发送次数
    const SMS_TIMES_PREFIX = 'user_sms_times:';

    //设置用户ID,密码
    const INIT_PWD = <<<EOF
-- 用户ID
redis.pcall('set', KEYS[1], ARGV[1], 'EX', ARGV[3]);
-- 访客密码
redis.pcall('set', KEYS[2], ARGV[2], 'EX', ARGV[3]);
EOF;

    //延长过期时间
    const EXTEND_EXPIRE = <<<EOF
-- 用户ID
redis.pcall('expire', KEYS[1], ARGV[1]);
-- 访客密码
redis.pcall('expire', KEYS[2], ARGV[1]);
EOF;

    public function __construct()
    {
        $this->auth = auth()->userOrFail();
    }
    
    /*
     * 访客密码管理
     * */
    public function visitorPwd()
    {
        //访客[开门,提交访客表单]密码
        //判断密码是否存在,存在:读取 不存在:生成
        $pwd = Redis::get(self::USER_VISITOR_ID_PREFIX . $this->auth->uuid);

        if (!$pwd) {
            //去重生成
            do {
                //生成密码
                $pwd = mt_rand(1000, 9999);
            } while (Redis::exists(self::USER_VISITOR_PWD_PREFIX . $this->auth->community_uuid . ':' . $pwd));

            try {
                Redis::eval(self::INIT_PWD, 2, self::USER_VISITOR_ID_PREFIX . $this->auth->uuid, self::USER_VISITOR_PWD_PREFIX . $this->auth->community_uuid .  ':' .$pwd, $pwd, $this->auth->uuid, 1800);

                //取值
                $pwd = (string)$pwd;
            } catch (\Exception $exception) {
                return $this->beJson(-1, '刷新失败:' . $exception->getMessage());
            }
        }

        return $this->beJson(0, 'success', compact('pwd'));
    }

    /*
     * 访客密码刷新
     * */
    public function refreshVisitorPwd()
    {
        //删除当前可能存在的访客密码
        $old_pwd = Redis::get(self::USER_VISITOR_ID_PREFIX . $this->auth->uuid);
        Redis::del(self::USER_VISITOR_ID_PREFIX . $this->auth->uuid);
        if ($old_pwd) Redis::del(self::USER_VISITOR_PWD_PREFIX . $this->auth->community_uuid .  ':' .$old_pwd);

        //去重生成
        do {
            //生成密码
            $pwd = mt_rand(1000, 9999);
        } while (Redis::exists(self::USER_VISITOR_PWD_PREFIX . $this->auth->community_uuid .  ':' .$pwd));

        try {
            Redis::eval(self::INIT_PWD, 2, self::USER_VISITOR_ID_PREFIX . $this->auth->uuid, self::USER_VISITOR_PWD_PREFIX . $this->auth->community_uuid .  ':' .$pwd, $pwd, $this->auth->uuid, 1800);

            //取值
            $pwd = (string)$pwd;

            return $this->beJson(0, 'success', compact('pwd'));
        } catch (\Exception $exception) {
            return $this->beJson(-1, '刷新失败:' . $exception->getMessage());
        }
    }

    /*
     * 向访客发送临时密码短信
     * */
    public function sendPwdSms()
    {
        //获取用户当天已取消次数
        $send_times = Redis::get(self::SMS_TIMES_PREFIX . $this->auth->uuid);

        //每天允许最大次数
        $max = 5;

        if ($send_times && $send_times >= $max) {
            return $this->beJson(-1, '发送失败:每天最多只能发送' . $max . '次访客密码');
        }

        //访客手机号
        $mobile = request()->get('mobile');

        //手机号校验
        if (!is_numeric($mobile) || (strlen($mobile) != 11)) return $this->beJson(-1, '请填写11位手机号码');

        //获取当前临时密码
        $pwd = Redis::get(self::USER_VISITOR_ID_PREFIX . $this->auth->uuid);

        if (!$pwd) {
            return $this->beJson(-1, '发送失败:访客密码获取失败');
        }

        //短信通知内容
        $content = sprintf('您的小区单元临时开锁密码为:%s', $pwd);

        //短信发送
        if (false !== $this->sendSMS($mobile, $content)) {
            //成功,刷新当前临时密码过期时间,避免过快过期
            Redis::eval(self::EXTEND_EXPIRE, 2, self::USER_VISITOR_ID_PREFIX . $this->auth->uuid, self::USER_VISITOR_PWD_PREFIX . $this->auth->community_uuid .  ':' .$pwd, 1800);

            //更新用户当天已取消次数
            if ($send_times) {
                //递增
                Redis::incr(self::SMS_TIMES_PREFIX . $this->auth->uuid);
            } else {
                //新增
                //获取当前时间-24点时间差
                $current = time();
                $next = strtotime(date('Y-m-d', $current) . ' 00:00:00' . "+1 days");
                $expire = $next - $current;

                //缓存当天取消次数
                Redis::set(self::SMS_TIMES_PREFIX . $this->auth->uuid, 1, 'EX', $expire);
            }

            return $this->beJson(0, 'success');
        } else {
            //失败
            return $this->beJson(-1, '短信发送失败,请稍后再试');
        }
    }
}

临时密码的实现完全脱离了mysql,减少性能损耗