Openresty+Lua+Redis实现高性能缓存
一、背景
当我们的程序需要提供较高的并发访问时,往往需要在程序中引入缓存技术,通常都是使用Redis作为缓存,但是要再更进一步提升性能的话,就需要尽可能的减少请求的链路长度,比如可以将访问Redis缓存从Tomcat服务器提前Nginx
原本访问缓存逻辑
User---> Nginx -> Tomcat -> Redis
User---> Nginx -> Redis
二、介绍
1 OpenResty 介绍
OpenResty® 是一个基于 Nginx 与 Lua 的高性能 Web 平台,其内部集成了大量精良的 Lua 库、第三方模块以及大多数的依赖项。用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。
官网: OpenResty® - 开源官方站
2 Lua 介绍
Lua 是一个小巧的脚本语言。它是巴西里约热内卢天主教大学(Pontifical Catholic University of Rio de Janeiro)里的一个由Roberto Ierusalimschy、Waldemar Celes 和 Luiz Henrique de Figueiredo三人所组成的研究小组于1993年开发的。 其设计目的是为了通过灵活嵌入应用程序中从而为应用程序提供灵活的扩展和定制功能。Lua由标准C编写而成,几乎在所有操作系统和平台上都可以编译,运行。Lua并没有提供强大的库,这是由它的定位决定的。所以Lua不适合作为开发独立应用程序的语言。Lua 有一个同时进行的JIT项目,提供在特定平台上的即时编译功能。
推荐教程: Lua 教程 | 菜鸟教程
三、软件安装
1 OpenResty 安装
下载最新版本
上传到虚拟机的/usr/local 目录下,之后解压
这里前提是需要安装c语言编译器和Nginx依赖包(如已经安装过了跳过下面3个命令),否则下面的安装会报错的
yum install -y gcc
yum install -y pcre pcre-devel
yum install -y zlib zlib-devel
yum install -y openssl openssl-devel
进入到解压后的文件夹 openresty-1.25.3.1 中执行
./configure --prefix=/usr/local/openresty
正常的话,出现下面的画面说明执行成功了
然后执行make && make install
make
make install
执行完成后,可以看到在/usr/local 目录下多了一个openresty 目录
2 目录介绍
- bin目录:执行文件目录。
- lualib目录:这个目录存放的是OpenResty中使用的Lua库,主要分为ngx和resty两个子目录。
- nginx目录:这个目录存放的是OpenResty的nginx配置和可执行文件。
- luajit目录:luajit目录是LuaJIT的安装根目录,用于提供LuaJIT的运行环境和相关资源。
3 启动Nginx
nginx/sbin/nginx -c /usr/local/openresty/nginx/conf/nginx.conf
4 访问Nginx
在浏览器中输入虚拟机的地址http://192.168.31.115/
四、Openresty中初试Lua
1 编辑nginx.conf
在server{}中插入下面代码
location /lua {default_type text/html;content_by_lua 'ngx.say("<p>hello,world</p>")';}
2 重启一下Nginx
nginx/sbin/nginx -s stop
nginx/sbin/nginx -c /usr/local/openresty/nginx/conf/nginx.conf
3 访问浏览器
在浏览器中输入虚拟机的IP地址+lua
http://192.168.31.115/lua
正常的话应该可以看到下面的画面
4 通过lua文件的方式
进入到Nginx目录,创建lua文件夹,并新建一个hello.lua文件
cd nginx
mkdir lua
vim lua/hello.lua
ngx.say("<p>hello,hello,hello</p>")
修改nginx.conf 文件
location /lua {default_type text/html;content_by_lua_file lua/hello.lua;}
重启Nginx,再次刷新网站
5 Openresty连接Redis
参考官网文档:GitHub - openresty/lua-resty-redis: Lua redis client driver for the ngx_lua based on the cosocket API
在/usr/local/openresty/nginx/lua目录下,编辑一个redis.lua 文件,内容如下:
local redis = require "resty.redis"
local red = redis:new()red:set_timeouts(1000, 1000, 1000) -- 1 seclocal ok, err = red:connect("192.168.31.114", 6579)
if not ok thenngx.say("failed to connect: ", err)return
endlocal res, err = red:auth("123456")
if not res thenngx.say("failed to authenticate: ", err)return
endok, err = red:set("dog", "an animal")
if not ok thenngx.say("failed to set dog: ", err)return
endngx.say("set result: ", ok)local res, err = red:get("dog")
if not res thenngx.say("failed to get dog: ", err)return
endif res == ngx.null thenngx.say("dog not found.")return
endngx.say("dog: ", res)
再修改nginx.conf
location /lua {default_type text/html;content_by_lua_file lua/redis.lua;}
访问浏览器
到这里,我们已经成功使用Nginx通过lua脚本访问到了Redis,但是这种写法仍然有一个巨大的问题,就是每次请求都会重新连接Redis,性能非常低下,我们测试一下这样写的接口性能
6 解决Redis重复连接问题
再修改nginx.conf
require("my/cache").go()
五、真实案例
1 案例背景
应用程序中有一个接口/goods-center/getGoodsDetails 希望通过nginx先查询Redis缓存,缓存中没有就去应用服务中查询,然后把查询到结果缓存到Redis中
2 Nginx获取请求的参数
编辑conf/nginx.conf 中的server,添加下面配置
location /goods-center/getGoodsDetails {default_type application/json;content_by_lua_file lua/item.lua;}
然后在/usr/local/openresty/nginx/lua 目录中创建item.lua,添加下面lua代码
local args = ngx.req.get_uri_args()ngx.say(args["goodsId"])
重新加载nginx配置
sbin/nginx -s reload
浏览器演示
3 转发请求到后端服务
定义一些工具类,方便后续写代码时调用,在/usr/local/openresty/lualib/mylua/common.lua
在common.lua中添加http get工具方法
local function http_get(path,params)local resp = ngx.location.capture(path,{method = ngx.HTTP_GET,args = params})if not resp thenngx.log(ngx.ERR)ngx.exit(404)endreturn resp.body
endlocal _M = {http_get = http_get
}return _M
编辑/usr/local/openresty/nginx/lua/item.lua 文件
-- 导入common包
local common = require('mylua.common')
local http_get = common.http_getlocal args = ngx.req.get_uri_args()-- 查询商品信息
local itemJson = http_get("/goods-center/getGoodsDetails",args)ngx.say(itemJson)
修改/usr/local/openresty/nginx/conf/nginx.conf 添加下面代码
location /nginx-cache/goods-center/getGoodsDetails {default_type application/json;content_by_lua_file lua/item.lua;}location ~ ^/goods-center/ {proxy_pass http://192.168.31.112:9527;}
解释一下上面代码,将/nginx-cache/goods-center/getGoodsDetails 请求通过lua脚本处理,转发到/goods-center/getGoodsDetails ,再通过~ ^/goods-center/ 反向代理到应用服务器上http://192.168.31.112:9527;
演示:
4 优先查询Redis (官方并发会报错)
官网文档:GitHub - openresty/lua-resty-redis: Lua redis client driver for the ngx_lua based on the cosocket API
这应该是我到目前为止最想吐槽的开源软件了,按照官网的文档操作简直就是个玩具,无法商用
只要超过1个线程去压测就会报bad request 错误,这个在官网的局限性一栏中有提到,但是不明白为什么不解决,这个问题不解决,就无法商用,而且每次请求都会创建连接,性能巨差,关键这个问题在网上都很少有人提出过这个问题,包括一些教学视频,都是点到为止,根本没有测试过并发场景能不能用,我只要一并发测试就GG,怎么改都不行,翻了很多文档,都没有解决方案,如果有人有方案可以在评论区分享一下,互相学习
GitHub - openresty/lua-resty-redis: Lua redis client driver for the ngx_lua based on the cosocket API
先看代码
/usr/local/openresty/lualib/mylua/common.lua
local redis = require('resty.redis')
local red = redis:new()
red:set_timeouts(1000,1000,1000)local function get_from_redis(key)ngx.log(ngx.INFO,"redis init start .............")local ok,err = red:connect("192.168.31.114",6579)-- 连接失败if not ok thenngx.log(ngx.ERR,"connect redis error",err)return nilend-- 认证失败local res, err = red:auth("123456")if not res thenngx.say(ngx.ERR,"failed to authenticate: ", err)return nilendlocal resp,err = red:get(key)if not resp thenngx.log(ngx.ERR,"get from redis error ",err," key: ",key)return nilend-- 数据为空if resp == ngx.null thenngx.log(ngx.ERR,"this key is nil, key: ",key)return nilend-- 设置连接超时时间和连接池大小red:set_keepalive(600000, 100)return respendlocal function http_get(path,params)local resp = ngx.location.capture(path,{method = ngx.HTTP_GET,args = params})if not resp thenngx.log(ngx.ERR)ngx.exit(404)endreturn resp.body
endlocal _M = {http_get = http_get,get_from_redis = get_from_redis
}return _M
/usr/local/openresty/nginx/lua/item.lua
-- 导入common包
local _M = {}common = require('mylua.common')
http_get = common.http_get
get_from_redis = common.get_from_redisfunction _M.get_data()local args = ngx.req.get_uri_args()-- 先查询Redislocal itemJson = get_from_redis("goods-center:goodsInfo:" .. args["goodsId"])ngx.log(ngx.INFO,"get from redis itemJson, ",itemJson)if itemJson == nil then-- redis 没有,则查询服务器信息itemJson = http_get("/goods-center/getGoodsDetails",args)endngx.say(itemJson)
endreturn _M
修改/usr/local/openresty/nginx/conf/nginx.conf 添加下面代码
server {location /nginx-cache/goods-center/getGoodsDetails {default_type application/json;content_by_lua_block {require("lua/item").get_data()}}location ~ ^/goods-center/ {proxy_pass http://192.168.31.112:9527;}
单线程测试(没有报错,且有500多的吞吐量)
2个线程测试,有40%+ 的错误率,报错详情截图给了,线程越多报错越多
2024/02/04 21:56:06 [error] 21662#0: *390686 lua entry thread aborted: runtime error: /usr/local/openresty/lualib/resty/redis.lua:166: bad request
stack traceback:
coroutine 0:[C]: in function 'connect'/usr/local/openresty/lualib/resty/redis.lua:166: in function 'connect'/usr/local/openresty/lualib/mylua/common.lua:9: in function 'get_from_redis'./lua/item.lua:12: in function 'get_data'content_by_lua(nginx.conf:49):2: in main chunk, client: 192.168.31.32, server: localhost, request: "GET /nginx-cache/goods-center/getGoodsDetails?goodsId=10000 HTTP/1.1", host: "192.168.31.115"
5 使用ngx.shared.redis_pool连接池(并发不会报错)
/usr/local/openresty/lualib/mylua/common.lua
-- 引入 lua-resty-redis 模块
local redis = require "resty.redis"-- 获取 OpenResty 全局字典对象(连接池)
local redis_pool = ngx.shared.redis_pool-- Redis 连接池的最大连接数
local max_connections = 100-- Redis 服务器地址和端口
local redis_host = "192.168.31.114"
local redis_port = 6579-- 获取 Redis 连接
local function get_redis_connection()local red = redis_pool:get(redis_host)if not red thenngx.log(ngx.ERR, "create new : ", err)-- 创建一个新的 Redis 连接red = redis:new()-- 设置连接超时时间red:set_timeout(1000,1000,1000)-- 连接 Redis 服务器local ok, err = red:connect(redis_host, redis_port)if not ok thenngx.log(ngx.ERR, "Failed to connect to Redis: ", err)return nil, errendlocal res, err = red:auth("123456")if not res thenngx.say(ngx.ERR,"failed to authenticate: ", err)return nilend-- 将连接放入连接池redis_pool:set(redis_host, red, 600)endreturn red
endlocal function get_from_redis(key)local redis_conn, err = get_redis_connection()if not redis_conn thenngx.log(ngx.ERR, "Failed to get Redis connection: ", err)ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)end-- 获取失败local resp,err = redis_conn:get(key)if not resp thenngx.log(ngx.ERR,"get from redis error ",err," key: ",key)return nilend-- 数据为空if resp == ngx.null thenngx.log(ngx.ERR,"this key is nil, key: ",key)return nilend-- 设置连接超时时间redis_conn:set_keepalive(600000, max_connections)return resp
endlocal function http_get(path,params)local resp = ngx.location.capture(path,{method = ngx.HTTP_GET,args = params})if not resp thenngx.log(ngx.ERR)ngx.exit(404)endreturn resp.body
endlocal _M = {http_get = http_get,get_from_redis = get_from_redis
}return _M
/usr/local/openresty/nginx/lua/item.lua
-- 导入common包
local _M = {}common = require('mylua.common')
http_get = common.http_get
get_from_redis = common.get_from_redisfunction _M.get_data()local args = ngx.req.get_uri_args()-- 先查询Redislocal itemJson = get_from_redis("goods-center:goodsInfo:" .. args["goodsId"])ngx.log(ngx.INFO,"get from redis itemJson, ",itemJson)if itemJson == nil then-- redis 没有,则查询服务器信息itemJson = http_get("/goods-center/getGoodsDetails",args)endngx.say(itemJson)
endreturn _M
修改/usr/local/openresty/nginx/conf/nginx.conf 添加下面代码
lua_shared_dict redis_pool 100m;server {location /nginx-cache/goods-center/getGoodsDetails {default_type application/json;content_by_lua_block {require("lua/item").get_data()}}location ~ ^/goods-center/ {proxy_pass http://192.168.31.112:9527;}
6 压测详情
1 命中缓存压测
命中缓存的情况吞吐量1400多,并且采用本章第5小结这种线程池的方式连接不会报错,基本上可以商用了,唯一缺点是并发量没有达到预期,通过排查原因发现,大量的连接池并未真正生效,仍然有大量的创建连接,可能这是影响性能的主要因素,如果有同学解决了这个问题可以在评论区分享一下
2 未命中缓存压测
未命中缓存,会请求后端Tomcat服务器,Tomcat服务器会查询MySQL,这边的吞吐量测试数据为313,也不怎么高,排查了一下原因,仍然是Redis一直在不停的创建连接,这个问题目前还没有找到解决方案
六、总结
通过Nginx+lua 的方式,在Nginx这层就去查询Redis缓存,看起来的确是个非常棒的方案,但是缺点是操作起来特别麻烦,需要开发人员了解Nginx + Lua 还要了解Openresty 如何集成Nginx + Lua + Redis,还要掌握在这种方式下,能够使用好Redis的连接池。最关键的是目前这种技术的文档并不完善,代码在某些地方不是特别的成熟,网上能找到的资料都很少而且都比较皮毛,不够深入,然而开发人员却需要深入地了解他们,才能比较好的驾驭这种方式,这次探究仍然有一个遗留问题就是Openresty + Lua + Redis 连接池的方式,连接池看起来有时候会不生效,也不是每次都不生效,有一定的概率,从而导致性能并不高,这个需要后面再研究解决它
相关文章:

Openresty+Lua+Redis实现高性能缓存
一、背景 当我们的程序需要提供较高的并发访问时,往往需要在程序中引入缓存技术,通常都是使用Redis作为缓存,但是要再更进一步提升性能的话,就需要尽可能的减少请求的链路长度,比如可以将访问Redis缓存从Tomcat服务器…...
基于Vue2用keydown、keyup事件实现长按键盘任意键(或组合键)3秒触发自定义事件(以F1键为例)
核心代码 <template></template> <script> export default {created() {//监听长按快捷键addEventListener("keydown", this.keydown);addEventListener("keyup", this.keyup);},destroyed(d) {//移除长按快捷键removeEventListener(&…...

【C#】.net core 6.0 设置根目录下某个文件夹可访问,访问创建的图片等资源
欢迎来到《小5讲堂》 大家好,我是全栈小5。 这是《C#》系列文章,每篇文章将以博主理解的角度展开讲解, 特别是针对知识点的概念进行叙说,大部分文章将会对这些概念进行实际例子验证,以此达到加深对知识点的理解和掌握。…...

报错ValueError: Unknown CUDA arch (8.6) or GPU not supported
文章目录 问题描述解决方案参考文献 问题描述 报错 ValueError: Unknown CUDA arch (8.6) or GPU not supported 本人显卡为 RTX 3060,CUDA 为 10.2,PyTorch 为 1.5 解决方案 修改 C:\Users\Administrator\Envs\test\Lib\site-packages\torch\utils\c…...
Golang 并发 Cond条件变量
Golang 并发 Cond条件变量 背景 编写代码过程中, 通常有主协程和多个子协程进行协作的过程,比如通过 WaitGroup 可以实现当所有子协程完成之后, 主协程再继续执行。 如上的场景是主协程等待子协程达到某个状态再继续运行。 但是反过来怎么…...
linux 下 chrome 无法在设置里面配置代理的解决方法
文章目录 [toc]解决方法查找 chrome 命令路径查看 chrome 启动文件方式一方法二 在 linux 环境下,使用 chrome 没办法像 firefox 一样在设置里面配置代理,打开 chrome 的设置会有下面的内容显示 When running Google Chrome under a supported desktop e…...

C#上位机与三菱PLC的通信03--MC协议之A-1E报文解析
1、MC协议帧 MC协议可以在串口通信,也可以在以太网通信,有A-1E和Qna-3E两种模式,这两种都是三菱PLC通信协议中比较常用的两种,一般我们使用比较多的是以太网通信,对于FX5U系列/Q系列/Qna系列/L系列的PLC,…...

nodeJS 的 npm 设置国内高速镜像之淘宝镜像的方法
1、我们知道 nodeJS 是老外搞出来的,服务器放在了国外,国内的小朋友访问起来会比较慢,阿里巴巴的淘宝给出了有力支持,现在我们就将 nodeJS 的镜像地址切换为国内的淘宝镜像。 2、查看当前的镜像地址: npm get registr…...
Nginx方向代理和负载均衡配置
1. Nginx介绍 2.Nginx常用命令 cd /usr/local/nginx/sbin/ ./nginx 启动 ./nginx -s stop 停止 ./nginx -s quit 安全退出 ./nginx -s reload 重新加载配置文件 如果我们修改了配置文件,就需要重新加载。 ps aux|grep nginx 查看nginx进程3.nginx配置文件 …...

贪心算法篇
“靠漫步,将生趣填饱~” 贪心算法简介? 贪心算法(Greedy Algorithm),也称为贪婪算法,是一种在解决问题时采取贪心策略的方法。其基本原理是很简单的: “在每个决策点上都选择当下看似最好的选项…...
springboot/ssm大学生就业服务平台就业招聘宣传管理系统Java系统
springboot(ssm大学生就业服务平台 就业招聘宣传管理系统Java系统 开发语言:Java 框架:springboot(可改ssm) vue JDK版本:JDK1.8(或11) 服务器:tomcat 数据库:mysql…...

上下固定中间自适应布局
实现上下固定中间自适应布局 1.通过position:absolute实现 定义如下结构 <body> <div class"container"> <div class"top"></div> <div class"center"></div> <div class"bottom&…...

3分钟部署完成Docker Registry及可视化管理工具Docker-UI
安装docker-registry 由于镜像文件会非常占用空间,因此需要选择一个磁盘充裕的位置来存放镜像数据。 这里设置为:-v /data/registry:/var/lib/registry,其中/data/registry是宿主机存放数据的位置。 docker run -d -p 5000:5000 --restart…...

【npm】修改npm全局安装包的位置路径
问题 全局安装的默认安装路径为:C:\Users\admin\AppData\Roaming\npm,缓存路径为:C:\Users\admin\AppData\Roaming\npm_cache(其中admin为自己的用户名)。 由于默认的安装路径在C盘,太浪费C盘内存啦&#…...

数据库切片大对决:ShardingSphere与Mycat技术解析
欢迎来到我的博客,代码的世界里,每一行都是一个故事 数据库切片大对决:ShardingSphere与Mycat技术解析 前言ShardingSphere与Mycat简介工作原理对比功能特性对比 前言 在数据库的舞台上,有两位颇受欢迎的明星,它们分别…...

macbook电脑如何永久删除app软件?
在使用MacBook的过程中,我们经常会下载各种App来满足日常的工作和娱乐需求。然而,随着时间的积累,这些App不仅占据了宝贵的硬盘空间,还可能拖慢电脑的运行速度。那么,如何有效地管理和删除这些不再需要的App呢…...
安卓——计算器应用(Java)
步骤 1: 设置Android Studio项目 创建一个新的Android项目,选择Java作为编程语言。 步骤 2: 设计用户界面 打开activity_main.xml文件,在res/layout目录下,设计你的计算器用户界面。这个例子使用了LinearLayout来排列两个EditText输入框和…...
【笔记】Helm-5 Chart模板指南-8 命名模板
命名模板 此时需要越过模板,开始创建其他内容了。该部分我们会看到如何在一个文件中定义 命名模板,并在其他地方使用。命名模板(有时称作一个部分或一个子模板)仅仅是在文件内部定义的模板,并使用了一个名字。有两种创…...

Github 2024-02-08 开源项目日报 Top9
根据Github Trendings的统计,今日(2024-02-08统计)共有9个项目上榜。根据开发语言中项目的数量,汇总情况如下: 开发语言项目数量Ruby项目1HTML项目1Python项目1Scala项目1PLpgSQL项目1Rust项目1NASL项目1C项目1TypeScript项目1非开发语言项目…...

c语言贪食蛇游戏
演示视频 目录 一.概述 二.游戏开始前 修改控制台程序标题和大小 Win32 API GetStdHandle函数 GetConsoleCursorInfo函数和SetConsoleCursorInfo函数 SetConsoleCursorPosition函数 游戏开篇界面处理 创建地图 蛇身节点以及食物节点初始化 蛇身的初始化 整体蛇节点…...

接口测试中缓存处理策略
在接口测试中,缓存处理策略是一个关键环节,直接影响测试结果的准确性和可靠性。合理的缓存处理策略能够确保测试环境的一致性,避免因缓存数据导致的测试偏差。以下是接口测试中常见的缓存处理策略及其详细说明: 一、缓存处理的核…...
论文解读:交大港大上海AI Lab开源论文 | 宇树机器人多姿态起立控制强化学习框架(二)
HoST框架核心实现方法详解 - 论文深度解读(第二部分) 《Learning Humanoid Standing-up Control across Diverse Postures》 系列文章: 论文深度解读 + 算法与代码分析(二) 作者机构: 上海AI Lab, 上海交通大学, 香港大学, 浙江大学, 香港中文大学 论文主题: 人形机器人…...
服务器硬防的应用场景都有哪些?
服务器硬防是指一种通过硬件设备层面的安全措施来防御服务器系统受到网络攻击的方式,避免服务器受到各种恶意攻击和网络威胁,那么,服务器硬防通常都会应用在哪些场景当中呢? 硬防服务器中一般会配备入侵检测系统和预防系统&#x…...

UR 协作机器人「三剑客」:精密轻量担当(UR7e)、全能协作主力(UR12e)、重型任务专家(UR15)
UR协作机器人正以其卓越性能在现代制造业自动化中扮演重要角色。UR7e、UR12e和UR15通过创新技术和精准设计满足了不同行业的多样化需求。其中,UR15以其速度、精度及人工智能准备能力成为自动化领域的重要突破。UR7e和UR12e则在负载规格和市场定位上不断优化…...
Typeerror: cannot read properties of undefined (reading ‘XXX‘)
最近需要在离线机器上运行软件,所以得把软件用docker打包起来,大部分功能都没问题,出了一个奇怪的事情。同样的代码,在本机上用vscode可以运行起来,但是打包之后在docker里出现了问题。使用的是dialog组件,…...

C++使用 new 来创建动态数组
问题: 不能使用变量定义数组大小 原因: 这是因为数组在内存中是连续存储的,编译器需要在编译阶段就确定数组的大小,以便正确地分配内存空间。如果允许使用变量来定义数组的大小,那么编译器就无法在编译时确定数组的大…...
JS设计模式(4):观察者模式
JS设计模式(4):观察者模式 一、引入 在开发中,我们经常会遇到这样的场景:一个对象的状态变化需要自动通知其他对象,比如: 电商平台中,商品库存变化时需要通知所有订阅该商品的用户;新闻网站中࿰…...
Vue 模板语句的数据来源
🧩 Vue 模板语句的数据来源:全方位解析 Vue 模板(<template> 部分)中的表达式、指令绑定(如 v-bind, v-on)和插值({{ }})都在一个特定的作用域内求值。这个作用域由当前 组件…...

从物理机到云原生:全面解析计算虚拟化技术的演进与应用
前言:我的虚拟化技术探索之旅 我最早接触"虚拟机"的概念是从Java开始的——JVM(Java Virtual Machine)让"一次编写,到处运行"成为可能。这个软件层面的虚拟化让我着迷,但直到后来接触VMware和Doc…...

网页端 js 读取发票里的二维码信息(图片和PDF格式)
起因 为了实现在报销流程中,发票不能重用的限制,发票上传后,希望能读出发票号,并记录发票号已用,下次不再可用于报销。 基于上面的需求,研究了OCR 的方式和读PDF的方式,实际是可行的ÿ…...