案例:應(yīng)用輕量級(jí)編程快速匯總電子發(fā)票金額

原創(chuàng):顧遠(yuǎn)山
著作權(quán)歸作者所有,轉(zhuǎn)載請(qǐng)標(biāo)明出處。

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...


前言: 在日常生活中我們經(jīng)常會(huì)遇到一些實(shí)際問題,比如少量數(shù)據(jù)的非常規(guī)處理,人肉手工做又累又傻,現(xiàn)成的工具或平臺(tái)卻要么過于通用要么過于笨重以至于無法直接被應(yīng)用在特定場景,空有百般本領(lǐng)卻無從下手。也許真相是好多人對(duì)它們的功能不夠熟悉,例如筆者并不從事數(shù)據(jù)分析工作,類似Power BI這種入門級(jí)的簡單工具學(xué)了又忘忘了又學(xué)也用不好,一來是工作中缺乏足夠案例實(shí)踐,二來是年紀(jì)大了確實(shí)記不住。對(duì)于這種情況,輕量級(jí)編程可以靈活快速地把問題解決。

導(dǎo)讀: 這是一篇用輕量級(jí)編程方式解決實(shí)際問題后復(fù)盤的文章,主要圍繞軟件工程實(shí)踐中的設(shè)計(jì)和開發(fā)階段展開,順便推廣一下F#編程語言(和正則表達(dá)式)在日常生活中的應(yīng)用。

關(guān)鍵字: 輕量級(jí)編程軟件工程F#正則表達(dá)式

第零部分:問題描述

現(xiàn)有格式相同的電子發(fā)票PDF文件若干,我們使用Edge瀏覽器或Reader類工具打開它們后,能選取和復(fù)制里面的文字內(nèi)容,比如下面兩個(gè)截圖中,發(fā)票樣本1選取到的是發(fā)票金額¥1029.40,發(fā)票樣本2選取到的發(fā)票金額¥799.70,即藍(lán)色高亮部分。如果不想逐個(gè)點(diǎn)開PDF文件找到發(fā)票金額進(jìn)行復(fù)制粘貼或手抄匯總,如何快速求得這堆電子發(fā)票的總金額?

發(fā)票樣本1
發(fā)票樣本2

這個(gè)問題的實(shí)質(zhì)無非是數(shù)據(jù)抽取+類型轉(zhuǎn)換+數(shù)值計(jì)算,解決思路五花八門。同事S早前做過各大公司年報(bào)抽數(shù)分析的項(xiàng)目,她建議用輕量級(jí)編程的方法直接從PDF文件中讀取發(fā)票金額然后匯總求解。思路很有創(chuàng)意,那具體怎么求出這個(gè)值呢?實(shí)現(xiàn)的方式也是豐富多彩的,筆者使用了其中一種,僅供參考。

第一部分:高階設(shè)計(jì)

目標(biāo): 實(shí)現(xiàn)一個(gè)程序。
輸入: 一個(gè)包含電子發(fā)票文件的Windows文件夾。
輸出: 所有電子發(fā)票金額(含稅)的匯總值。
假設(shè): 該文件夾存在且可被訪問但沒有子文件夾,該文件夾里有符合指定格式的電子發(fā)票文件(PDF格式),且這些文件能被Edge或者Reader類工具打開并選取和復(fù)制發(fā)票金額。

高階設(shè)計(jì)

測(cè)試用例:

  1. 文件夾里只有文件20200831.pdf(發(fā)票含稅金額¥1029.40)時(shí),輸出1029.40。
  2. 文件夾里有文件20200831.pdf(發(fā)票含稅金額¥1029.40)和20200921(發(fā)票含稅金額¥799.70)時(shí),輸出1829.10。

第二部分:詳細(xì)設(shè)計(jì)

我們把期望實(shí)現(xiàn)的程序功能按模塊進(jìn)行了簡單分解。
主程序由三個(gè)子模塊組成,其中:

  • 子模塊1:收集待處理發(fā)票文件列表;
  • 子模塊2:對(duì)每個(gè)發(fā)票文件進(jìn)行操作,打開發(fā)票文件,獲取發(fā)票金額;
  • 子模塊3:匯總發(fā)票金額。
程序功能的模塊化分解

按函數(shù)式編程的范式進(jìn)一步把模塊對(duì)應(yīng)為函數(shù),則整個(gè)程序?qū)⒂伤膫€(gè)函數(shù)構(gòu)成,一個(gè)主函數(shù)(main)和三個(gè)子函數(shù)(getPDFsgetInvoiceAmountsumUp),四者與輸入輸出的關(guān)系如下圖所示:

main函數(shù)關(guān)系圖

其實(shí)上面的getInvoiceAmount函數(shù)有兩個(gè)坑,穩(wěn)妥起見先把它們填了:第一,打開目標(biāo)文件(PDF格式)并讀取所有內(nèi)容為文本并非編程語言的內(nèi)置功能,必須依賴第三方的包間接實(shí)現(xiàn)。第二,讀取出來的文本是一個(gè)字符串,實(shí)現(xiàn)時(shí)把字符串里所有(¥數(shù)值)全部抓出來取最大值即可。為此我們對(duì)getInvoiceAmount函數(shù)進(jìn)一步分解為兩個(gè)子函數(shù)readAllTextgetTargetValue,三者與輸入輸出的關(guān)系如下圖所示:

getInvoiceAmount函數(shù)關(guān)系圖

通過把高階設(shè)計(jì)分解為主程序和三個(gè)模塊,對(duì)應(yīng)的四個(gè)函數(shù)(加兩個(gè)子函數(shù))組合起來可以實(shí)現(xiàn)程序期望的功能,小結(jié)如下:

  • main: string -> decimal
  • getPDFs: string -> string []
  • getInvoiceAmount: string -> decimal
    • readAllText: string -> string
    • getTargetValue: string -> decimal
  • sumUp: decimal [] -> decimal

第三部分:代碼實(shí)現(xiàn)

準(zhǔn)備條件: 創(chuàng)建F# Console Application (.NET Framework 4.7+)解決方案,通過Nuget Package Manager安裝PDFSharp包(最新穩(wěn)定版1.50.5147),并打開代碼實(shí)現(xiàn)所依賴的以下命名空間:

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用于通過正則表達(dá)式從文本中獲取目標(biāo)值,其他是PDFSharp相關(guān)的命名空間。
實(shí)現(xiàn)詳細(xì)設(shè)計(jì)里面的四個(gè)函數(shù)(加兩個(gè)子函數(shù))
由于main函數(shù)在最后調(diào)用,我們先實(shí)現(xiàn)它的三個(gè)子函數(shù),然后再實(shí)現(xiàn)它。

  • getPDFs函數(shù)
let getPDFs pathIn = 
    pathIn 
    |> Directory.GetFiles //獲取該文件夾下所有文件
    |> Array.filter (fun f -> f.ToLower().EndsWith(".pdf"))//篩選PDF文件
  • getInvoiceAmount函數(shù)
    詳細(xì)設(shè)計(jì)中提到,實(shí)現(xiàn)這個(gè)函數(shù)需要先實(shí)現(xiàn)它的兩個(gè)子函數(shù)readAllTextgetTargetValue,我們逐個(gè)實(shí)現(xiàn)。

    • readAllText函數(shù)
      F# Snippets的網(wǎng)站上,直接有可用的代碼,直接引用。

    • getTargetValue函數(shù)

let getTargetValue filecontent =
    let regex = new Regex(@"(?<=¥)\d+?\.\d+?") //獲取金額文本的正則表達(dá)式
    filecontent  |> regex.Matches |> Seq.cast //詳細(xì)設(shè)計(jì)中的getMatchedStrings
    |> Seq.map (fun m -> m.Value |> decimal) //詳細(xì)設(shè)計(jì)中的decimal 
    |> Seq.max //詳細(xì)設(shè)計(jì)中的max

實(shí)現(xiàn)了readAllText子函數(shù)和getTargetValue子函數(shù)之后,根據(jù)詳細(xì)設(shè)計(jì)易得:

let getInvoiceAmount filename = filename |> readAllText |> getTargetValue
  • sumUp函數(shù)
    F#內(nèi)置有匯總函數(shù)Array.sum,直接使用。

  • main函數(shù)
    基于上述三個(gè)子函數(shù)的實(shí)現(xiàn),根據(jù)詳細(xì)設(shè)計(jì)即得:

let main pathIn = pathIn |> getPDFs |> Array.map getInvoiceAmount |> Array.sum

把實(shí)現(xiàn)完畢的main函數(shù)對(duì)比高階設(shè)計(jì)里的概念圖,數(shù)據(jù)流過程并無二致。

至此四個(gè)函數(shù)(加兩個(gè)子函數(shù))都已用F#代碼實(shí)現(xiàn)完畢,設(shè)定輸入?yún)?shù)pathIn便可運(yùn)行測(cè)試。

第四部分:用戶接受測(cè)試

測(cè)試用例1:
期待值1029.40,實(shí)際值1029.40,通過。

測(cè)試用例1驗(yàn)證通過

測(cè)試用例2:
期待值1829.10,實(shí)際值1829.10,通過。

測(cè)試用例2驗(yàn)證通過

測(cè)試用例驗(yàn)證通過后,筆者認(rèn)為用戶驗(yàn)收測(cè)試完成,程序可用,問題解決。

結(jié)語

筆者最后用這個(gè)小程序快速匯總了60個(gè)PDF電子發(fā)票文件的含稅總金額,非常方便。之所以說這是輕量級(jí)編程解決方案,是因?yàn)槌ヒ玫耐獠看a之外,實(shí)現(xiàn)所有功能只需要不到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#進(jìn)行輕量級(jí)編程解決實(shí)際問題

時(shí)下很多人都在學(xué)Python,對(duì)于日常應(yīng)用類的輕量級(jí)編程非常容易上手。但其實(shí)F#也同樣適合這種場景,而且很多時(shí)候F#的語法比其他語言更簡潔。
比如F#中被廣泛應(yīng)用的前向管道運(yùn)算符|>,它的定義為:

let (|>) x f = f x

前向管道運(yùn)算符|>可以非常直觀地把輸入輸出按照流的形式直接串起來,相比其他語言省了不少括號(hào)從而增加了代碼的可讀性。這個(gè)運(yùn)算符在函數(shù)式編程語言里其實(shí)是標(biāo)配。
我們不妨用縮進(jìn)的方式細(xì)看一下本案例的main函數(shù):

let main pathIn = 
    pathIn 
    |> getPDFs //輸入文件夾路徑,獲取文件夾里所有PDF文件名
    |> Array.map getInvoiceAmount //對(duì)每個(gè)PDF文件,獲取里面的含稅發(fā)票金額
    |> Array.sum //匯總所有含稅發(fā)票金額

這樣的代碼,數(shù)據(jù)流的順序基本遵循業(yè)務(wù)邏輯,即便不是程序員也能猜個(gè)七七八八。但同樣的邏輯如果換成C#來寫,就算用上Linq的擴(kuò)展方法也最多精簡如下:

public static int Main(string pathIn)
{
    return getPDFs(pathIn).Select(file=>getInvoiceAmount(file)).Sum();
}

其中各種括號(hào)和莫名其妙的關(guān)鍵字,邏輯要再復(fù)雜一點(diǎn)的話別說業(yè)務(wù)人員了,就算程序員讀起來恐怕也是云里霧里。

另外,在F#中代碼的復(fù)用比其他語言更靈活,因?yàn)椴煌暮瘮?shù)之間可以相互之間組合產(chǎn)生新的函數(shù),而這些函數(shù)又可以作為參數(shù)傳給高階函數(shù)進(jìn)行運(yùn)算。函數(shù)組合運(yùn)算符>>的應(yīng)用也是相當(dāng)高頻,比如本案例中的main函數(shù),就算我們沒有顯式實(shí)現(xiàn)getInvoiceAmount,也可以臨時(shí)用readAllTextgetTargetVaule組合起來用,于是有:

let main pathIn = 
    pathIn 
    |> getPDFs
    |> Array.map (readAllText >> getTargetValue) //臨時(shí)組合的匿名函數(shù)作為高階函數(shù)的入?yún)?    |> Array.sum

>>操作符我們很方便就把readAllTextgetTargetValue兩個(gè)函數(shù)結(jié)合成一個(gè)匿名函數(shù),然后這個(gè)匿名函數(shù)被作為參數(shù)傳到Array.map高階函數(shù)里參與計(jì)算。這個(gè)操作符在函數(shù)式編程語言里同樣是標(biāo)配。

F#中也有語法糖。還用本案例中的main函數(shù)舉例,Array.map f array |> Array.sum等效為Array.sumBy f array,所以這句代碼可以寫得更簡潔一些:

let main pathIn = 
    pathIn 
    |> getPDFs 
    |> Array.sumBy (readAllText >> getTargetValue)

其實(shí)函數(shù)式編程語言還有很多有趣且實(shí)用的特性。比如函數(shù)調(diào)用傳入?yún)?shù)可以不加括號(hào)這一點(diǎn),就讓有些寫得足夠好的F#代碼看起來跟自然語言(英文)相當(dāng)接近,甚至一般人也能看懂,所以F#的用戶群里有固定一部分是做領(lǐng)域特定語言編程的。領(lǐng)域特定語言是另一個(gè)話題了,就算只用于解決日常小問題,筆者還是強(qiáng)烈推薦產(chǎn)品經(jīng)理學(xué)一學(xué)F#這門開源的全平臺(tái)語言,挺有用的。

另外,既然PDF格式的文件有特定的文件結(jié)構(gòu),為什么不通過文件結(jié)構(gòu)分析獲取發(fā)票金額?這樣做的確沒問題,但筆者不熟悉PDFSharp包深入研究必然花費(fèi)一定時(shí)間,且筆者有信心用正則表達(dá)式能把目標(biāo)值提取出來,就直接讀取PDF所有內(nèi)容為文本了。實(shí)際上抽取發(fā)票金額的正則表達(dá)式很短,各部分用不同的顏色標(biāo)注如下:

案例中的正則表達(dá)式拆解

  • (?<=¥)為肯定式后向查找字符¥,零寬度斷言,僅匹配不捕獲
  • \d+?,向前惰性匹配所有數(shù)字,直到遇到第一個(gè)非數(shù)字字符(得到整數(shù)部分1029)
  • \.,字符 . 是正則表達(dá)式里的保留字(通過\字符轉(zhuǎn)義得到普通字符 . )
  • \d+?,向前惰性匹配所有數(shù)字,直到遇到第一個(gè)非數(shù)字字符(得到小數(shù)部分40)

正則表達(dá)式簡單暴力可行,但并不是出色的解決方案,慎用。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請(qǐng)通過簡信或評(píng)論聯(lián)系作者。