原创:顾远山
著作权归作者所有,转载请标明出处。
TALK IS CHEAP! SHOW ME YOUR CODE!
OK... Here comes my code...
let main pathIn =
pathIn |> Directory.GetFiles |> Array.filter (fun f -> f.ToLower().EndsWith(".pdf"))
|> Array.map (fun filename -> filename |> readAllText |> Regex(@"(?<=¥)\d+?\.\d+?").Matches |> Seq.cast<Match> |> Seq.map (fun m -> m.Value |> decimal) |> Seq.max)
|> Array.sum
!@##@!
CODE IS CHEAP! SHOW ME YOUR POINT!
OK... Here comes my point...
前言: 在日常生活中我们经常会遇到一些实际问题,比如少量数据的非常规处理,人肉手工做又累又傻,现成的工具或平台却要么过于通用要么过于笨重以至于无法直接被应用在特定场景,空有百般本领却无从下手。也许真相是好多人对它们的功能不够熟悉,例如笔者并不从事数据分析工作,类似Power BI这种入门级的简单工具学了又忘忘了又学也用不好,一来是工作中缺乏足够案例实践,二来是年纪大了确实记不住。对于这种情况,轻量级编程可以灵活快速地把问题解决。
导读: 这是一篇用轻量级编程方式解决实际问题后复盘的文章,主要围绕软件工程实践中的设计和开发阶段展开,顺便推广一下F#编程语言(和正则表达式)在日常生活中的应用。
关键字: 轻量级编程,软件工程,F#,正则表达式
第零部分:问题描述
现有格式相同的电子发票PDF文件若干,我们使用Edge浏览器或Reader类工具打开它们后,能选取和复制里面的文字内容,比如下面两个截图中,发票样本1选取到的是发票金额¥1029.40,发票样本2选取到的发票金额¥799.70,即蓝色高亮部分。如果不想逐个点开PDF文件找到发票金额进行复制粘贴或手抄汇总,如何快速求得这堆电子发票的总金额?
这个问题的实质无非是数据抽取+类型转换+数值计算,解决思路五花八门。同事S早前做过各大公司年报抽数分析的项目,她建议用轻量级编程的方法直接从PDF文件中读取发票金额然后汇总求解。思路很有创意,那具体怎么求出这个值呢?实现的方式也是丰富多彩的,笔者使用了其中一种,仅供参考。
第一部分:高阶设计
目标: 实现一个程序。
输入: 一个包含电子发票文件的Windows文件夹。
输出: 所有电子发票金额(含税)的汇总值。
假设: 该文件夹存在且可被访问但没有子文件夹,该文件夹里有符合指定格式的电子发票文件(PDF格式),且这些文件能被Edge或者Reader类工具打开并选取和复制发票金额。
测试用例:
- 文件夹里只有文件20200831.pdf(发票含税金额¥1029.40)时,输出1029.40。
- 文件夹里有文件20200831.pdf(发票含税金额¥1029.40)和20200921(发票含税金额¥799.70)时,输出1829.10。
第二部分:详细设计
我们把期望实现的程序功能按模块进行了简单分解。
主程序由三个子模块组成,其中:
- 子模块1:收集待处理发票文件列表;
- 子模块2:对每个发票文件进行操作,打开发票文件,获取发票金额;
- 子模块3:汇总发票金额。
按函数式编程的范式进一步把模块对应为函数,则整个程序将由四个函数构成,一个主函数(main
)和三个子函数(getPDFs
、getInvoiceAmount
、sumUp
),四者与输入输出的关系如下图所示:
其实上面的getInvoiceAmount
函数有两个坑,稳妥起见先把它们填了:第一,打开目标文件(PDF格式)并读取所有内容为文本并非编程语言的内置功能,必须依赖第三方的包间接实现。第二,读取出来的文本是一个字符串,实现时把字符串里所有(¥数值)全部抓出来取最大值即可。为此我们对getInvoiceAmount
函数进一步分解为两个子函数readAllText
和getTargetValue
,三者与输入输出的关系如下图所示:
通过把高阶设计分解为主程序和三个模块,对应的四个函数(加两个子函数)组合起来可以实现程序期望的功能,小结如下:
main: string -> decimal
getPDFs: string -> string []
-
getInvoiceAmount: string -> decimal
readAllText: string -> string
getTargetValue: string -> decimal
sumUp: decimal [] -> decimal
第三部分:代码实现
准备条件: 创建F# Console Application (.NET Framework 4.7+)解决方案,通过Nuget Package Manager安装PDFSharp
包(最新稳定版1.50.5147),并打开代码实现所依赖的以下命名空间:
open System.Text
open PdfSharp.Pdf.IO
open PdfSharp.Pdf.Content
open PdfSharp.Pdf.Content.Objects
open System.Text.RegularExpressions
open System.IO
System.IO
用于获取文件夹里的文件名,System.Text.RegularExpressions
用于通过正则表达式从文本中获取目标值,其他是PDFSharp
相关的命名空间。
实现详细设计里面的四个函数(加两个子函数)
由于main
函数在最后调用,我们先实现它的三个子函数,然后再实现它。
-
getPDFs
函数
let getPDFs pathIn =
pathIn
|> Directory.GetFiles //获取该文件夹下所有文件
|> Array.filter (fun f -> f.ToLower().EndsWith(".pdf"))//筛选PDF文件
-
getInvoiceAmount
函数
详细设计中提到,实现这个函数需要先实现它的两个子函数readAllText
和getTargetValue
,我们逐个实现。readAllText
函数
在F# Snippets的网站上,直接有可用的代码,直接引用。getTargetValue
函数
let getTargetValue filecontent =
let regex = new Regex(@"(?<=¥)\d+?\.\d+?") //获取金额文本的正则表达式
filecontent |> regex.Matches |> Seq.cast //详细设计中的getMatchedStrings
|> Seq.map (fun m -> m.Value |> decimal) //详细设计中的decimal
|> Seq.max //详细设计中的max
实现了readAllText
子函数和getTargetValue
子函数之后,根据详细设计易得:
let getInvoiceAmount filename = filename |> readAllText |> getTargetValue
sumUp
函数
F#内置有汇总函数Array.sum
,直接使用。main函数
基于上述三个子函数的实现,根据详细设计即得:
let main pathIn = pathIn |> getPDFs |> Array.map getInvoiceAmount |> Array.sum
把实现完毕的main
函数对比高阶设计里的概念图,数据流过程并无二致。
至此四个函数(加两个子函数)都已用F#代码实现完毕,设定输入参数pathIn便可运行测试。
第四部分:用户接受测试
测试用例1:
期待值1029.40,实际值1029.40,通过。
测试用例2:
期待值1829.10,实际值1829.10,通过。
测试用例验证通过后,笔者认为用户验收测试完成,程序可用,问题解决。
结语
笔者最后用这个小程序快速汇总了60个PDF电子发票文件的含税总金额,非常方便。之所以说这是轻量级编程解决方案,是因为除去引用的外部代码之外,实现所有功能只需要不到10行代码,如下:
let getPDFs pathIn = pathIn |> Directory.GetFiles
|> Array.filter (fun f -> f.ToLower().EndsWith(".pdf"))
let getTargetValue filecontent =
let regex = new Regex(@"(?<=¥)\d+?\.\d+?")
filecontent |> regex.Matches |> Seq.cast<Match>
|> Seq.map (fun m -> m.Value |> decimal)
|> Seq.max
let getInvoiceAmount filename = filename |> readAllText |> getTargetValue
let main pathIn = pathIn |> getPDFs |> Array.map getInvoiceAmount |> Array.sum
使用F#进行轻量级编程解决实际问题
时下很多人都在学Python,对于日常应用类的轻量级编程非常容易上手。但其实F#也同样适合这种场景,而且很多时候F#的语法比其他语言更简洁。
比如F#中被广泛应用的前向管道运算符|>
,它的定义为:
let (|>) x f = f x
前向管道运算符|>
可以非常直观地把输入输出按照流的形式直接串起来,相比其他语言省了不少括号从而增加了代码的可读性。这个运算符在函数式编程语言里其实是标配。
我们不妨用缩进的方式细看一下本案例的main
函数:
let main pathIn =
pathIn
|> getPDFs //输入文件夹路径,获取文件夹里所有PDF文件名
|> Array.map getInvoiceAmount //对每个PDF文件,获取里面的含税发票金额
|> Array.sum //汇总所有含税发票金额
这样的代码,数据流的顺序基本遵循业务逻辑,即便不是程序员也能猜个七七八八。但同样的逻辑如果换成C#来写,就算用上Linq的扩展方法也最多精简如下:
public static int Main(string pathIn)
{
return getPDFs(pathIn).Select(file=>getInvoiceAmount(file)).Sum();
}
其中各种括号和莫名其妙的关键字,逻辑要再复杂一点的话别说业务人员了,就算程序员读起来恐怕也是云里雾里。
另外,在F#中代码的复用比其他语言更灵活,因为不同的函数之间可以相互之间组合产生新的函数,而这些函数又可以作为参数传给高阶函数进行运算。函数组合运算符>>
的应用也是相当高频,比如本案例中的main
函数,就算我们没有显式实现getInvoiceAmount
,也可以临时用readAllText
和getTargetVaule
组合起来用,于是有:
let main pathIn =
pathIn
|> getPDFs
|> Array.map (readAllText >> getTargetValue) //临时组合的匿名函数作为高阶函数的入参
|> Array.sum
用>>
操作符我们很方便就把readAllText
和getTargetValue
两个函数结合成一个匿名函数,然后这个匿名函数被作为参数传到Array.map
高阶函数里参与计算。这个操作符在函数式编程语言里同样是标配。
F#中也有语法糖。还用本案例中的main函数举例,Array.map f array |> Array.sum
等效为Array.sumBy f array
,所以这句代码可以写得更简洁一些:
let main pathIn =
pathIn
|> getPDFs
|> Array.sumBy (readAllText >> getTargetValue)
其实函数式编程语言还有很多有趣且实用的特性。比如函数调用传入参数可以不加括号这一点,就让有些写得足够好的F#代码看起来跟自然语言(英文)相当接近,甚至一般人也能看懂,所以F#的用户群里有固定一部分是做领域特定语言编程的。领域特定语言是另一个话题了,就算只用于解决日常小问题,笔者还是强烈推荐产品经理学一学F#这门开源的全平台语言,挺有用的。
另外,既然PDF格式的文件有特定的文件结构,为什么不通过文件结构分析获取发票金额?这样做的确没问题,但笔者不熟悉PDFSharp包深入研究必然花费一定时间,且笔者有信心用正则表达式能把目标值提取出来,就直接读取PDF所有内容为文本了。实际上抽取发票金额的正则表达式很短,各部分用不同的颜色标注如下:
- (?<=¥)为肯定式后向查找字符¥,零宽度断言,仅匹配不捕获
- \d+?,向前惰性匹配所有数字,直到遇到第一个非数字字符(得到整数部分1029)
-
\.
,字符 . 是正则表达式里的保留字(通过\
字符转义得到普通字符 . ) - \d+?,向前惰性匹配所有数字,直到遇到第一个非数字字符(得到小数部分40)
正则表达式简单暴力可行,但并不是出色的解决方案,慎用。