Excel 也可以玩 REST (3)

接下來,設計一個以 Excel 作爲用戶界面,通過 HTTP Request 對數據庫進行 CRUD 操作的實現。我們在日常工作中,經常需要用 Excel 來記錄事件和數據,比如,在項目實施的過程中,記錄和跟進實施過程中的問題、任務分派等等。但如果不是專門的軟件,如 Redmine ,基於 Excel 文件記錄數據還是有很多不便之處的。比如版本衝突,多個人員不能同時編輯數據等等。

這個時候,用 Excel 作爲前端界面,實現在線的數據輸入和數據同步,不失爲一個好的方式。但常規的方法中,Excel 與數據庫交互,需要藉助諸如 ADO 這樣的數據訪問模型。一般來說,每一臺 PC 都需要安裝相關驅動。比如,如果在 Linux 操作系統上部署 MySQL 數據庫,那麼通過 ADO 的數據訪問數據庫的話,可能採用 ODBC,需要爲每一臺 PC 安裝 MySQL for ODBC 驅動。

但 Excel 基於 HTTP Request 的話,從理論上來說,只要有網絡,就可以實現 CRUD ,達到在線輸入的要求。所以在本篇中,我將介紹如何用 WinHttp COM 對象 ,藉助 Http Request,實現對 MySQL 數據庫的增刪改查。

當然,前提是有服務器端提供的 Restful API。我在前面相關文章中,使用不同的方法實現過 Restful API,比如 Pthon Flask、 SAP Web Service 和 Node.js 等等,都提供瞭如何實現 Restful API 的說明,感興趣的讀者可以參考我的文章,或者網絡上其他文章。如果是非開發人員,使用其他語言實現 Restful API 可能有一定難度。

我的相關文章鏈接:
- Flask 實現 Rest API
- SAP 如何提供 RESTful Web 服務?
- SAP 如何提供 RESTful Web 服務(2) - ABAP 與 JSON
- SAP 如何提供 RESTful Web 服務(3) - Rest 路徑處理
- SAP Hana 數據庫編程接口 - Node.js

Json 數據轉換

Json 數據轉換使用 Github 上的 VBA-Json 模塊。前面的文章也介紹了使用方法。

封裝 HTTP Request 方法

爲了使用方便,對 Http Request 進行封裝,封裝爲四個方法:

  • doGet: 處理 GET 請求
  • doPost: 處理 POST 請求
  • doPut: 處理 PUT 請求
  • doDelete:處理 DELETE 請求

代碼放在 HttpRequests 模塊,完整的代碼如下:

Option Explicit

Public Type HttpResponse
    Status As Long
    ResponseText As String
    StatusText As String
End Type

Public Function doGet(url As String) As HttpResponse
    On Error GoTo errHandler

    Dim httpReq As WinHttp.WinHttpRequest
    Dim httpResp As HttpResponse

    Set httpReq = CreateObject("WinHttp.WinHttpRequest.5.1")

    httpReq.SetTimeouts 60000, 60000, 60000, 60000
    httpReq.Open "GET", url, False
    httpReq.Send

    httpResp.Status = httpReq.Status
    httpResp.ResponseText = httpReq.ResponseText
    httpResp.StatusText = httpReq.StatusText

    doGet = httpResp
    Exit Function

errHandler:
    MsgBox Err.Description
    Exit Function
End Function


Public Function doPost(url As String, payload As String) As HttpResponse
On Error GoTo errHandler
    Dim httpReq As WinHttp.WinHttpRequest
    Dim httpResp As HttpResponse

    Set httpReq = CreateObject("WinHttp.WinHttpRequest.5.1")

    httpReq.Open "POST", url, False
    httpReq.SetRequestHeader "Content-Type", "application/json"

    httpReq.Send payload

    httpResp.Status = httpReq.Status
    httpResp.ResponseText = httpReq.ResponseText
    httpResp.StatusText = httpReq.StatusText

    doPost = httpResp
    Exit Function

errHandler:
    MsgBox Err.Description
    Exit Function
End Function


Public Function doPut(url As String, payload As String) As HttpResponse
On Error GoTo errHandler
    Dim httpReq As WinHttp.WinHttpRequest
    Dim httpResp As HttpResponse

    Set httpReq = CreateObject("WinHttp.WinHttpRequest.5.1")

    httpReq.Open "PUT", url, False
    httpReq.SetRequestHeader "Content-Type", "application/json"

    httpReq.Send payload

    httpResp.Status = httpReq.Status
    httpResp.ResponseText = httpReq.ResponseText
    httpResp.StatusText = httpReq.StatusText

    doPut = httpResp
    Exit Function

errHandler:
    MsgBox Err.Description
    Exit Function
End Function


Public Function doDelete(url As String) As HttpResponse
On Error GoTo errHandler
    Dim httpReq As WinHttp.WinHttpRequest
    Dim httpResp As HttpResponse

    Set httpReq = CreateObject("WinHttp.WinHttpRequest.5.1")

    httpReq.Open "DELETE", url, False
    httpReq.Send

    httpResp.Status = httpReq.Status
    httpResp.ResponseText = httpReq.ResponseText
    httpResp.StatusText = httpReq.StatusText

    doDelete = httpResp
    Exit Function

errHandler:
    MsgBox Err.Description
    Exit Function
End Function

CRUD 的請求

後臺使用 MySQL 數據庫,表名爲 emp_master。表的創建腳本和示例數據請參考:Flask 實現 Rest API。代碼放在 Employee_CRUD 模塊,主要是進一步封裝,簡化前端的調用。

Option Explicit

Public Const BASE_URL As String = "http://localhost:5000"

Public Function get_employees() As HttpResponse
    Dim resp As HttpResponse
    resp = doGet(BASE_URL & "/employees")
    get_employees = resp
End Function

Public Function create_employee(payload As String) As HttpResponse
    Dim resp As HttpResponse
    resp = doPost(BASE_URL & "/employees/create", payload)

    create_employee = resp
End Function

Public Function modify_employee(empId As Integer, payload As String) As HttpResponse
    Dim resp As HttpResponse
    resp = doPut(BASE_URL & "/employees/" & empId, payload)

    modify_employee = resp
End Function

Public Function delete_employee_by_id(empId As Integer) As HttpResponse
    Dim resp As HttpResponse
    resp = doDelete(BASE_URL & "/employees/" & empId)

    delete_employee_by_id = resp
End Function

至此,後臺功能全部完畢。

界面實現邏輯

下面說明前端的實現方式。首先我們看一看前端的界面,以及我的思路:

數據刷新、提交修改等功能,都通過 “超鏈接” 的方法實現,沒有使用按鈕控件,這樣界面更加清爽。當用戶在數據區域操作時,自動對用戶所在行的狀態進行記錄。當用戶修改了數據,所在行的 A 列自動標記 M。如果點擊插入新行,在現有數據下面插入一行,並且所在行的 A 列自動標記爲 N。如果需要刪除某行,則在 A 列的所在行輸入 D。點擊提交修改按鈕,新增、修改和刪除的記錄被提交到後臺。

數據區域使用 Table 表格來實現

Excel 提供了一個叫做 Table 的對象,與一般的數據區域 Range 不同,Table 對象在數據操作、界面自動化等多個方面都更加強大。Table 對象創建的方法,就是選定一個區域,然後 CTRL + T。Table 在 VBA 中被稱作 ListObject,比操作 Range 要方便很多。因爲篇幅原因,不對 ListObject做過多解釋。

工作表保護與取消保護

在線編輯涉及多個用戶,Excel 界面不能是沒有任何制約,否則可能導致服務器端數據的衝突和數據毀損。所以我通過 VBA 代碼,對 Excel 工作表進行保護,需要的時候通過代碼取消保護。

Public Sub setWorksheetProtection(sht As Worksheet)
    Dim editRange As AllowEditRange
    For Each editRange In sht.Protection.AllowEditRanges
        editRange.Delete
    Next
    sht.Protection.AllowEditRanges.Add Title:="EditArea", Range:=sht.ListObjects("EmpTable").DataBodyRange
    sht.Protection.AllowEditRanges.Add Title:="ActionFlag", Range:=sht.Range("A:A")

    sht.Protect DrawingObjects:=True, Contents:=True, Scenarios:=True _
        , AllowFormattingCells:=True, AllowFormattingColumns:=True, _
        AllowFormattingRows:=True, AllowFiltering:=True, Password:="123456"
End Sub


Public Sub removeWorkSheetProtection(sht As Worksheet)
    sht.Unprotect Password:="123456"
End Sub

行項目狀態的自動標記

自動標記通過 Workbook_SheetChange 事件來實現。當然,我們不能始終都觸發這些事件,所以,我用一個全局變量 isRecordingChange 來記錄是否要自動記錄修改。

Public isRecordingChange As Boolean

Public Sub setRecordingFlag(flag As Boolean)
    isRecordingChange = flag
End Sub

工作簿打開的時候,isRecordingChange 爲 True:

Private Sub Workbook_Open()
    setRecordingFlag True
End Sub

如果用戶在數據區域 (用戶可編輯的數據區域爲 ListObject EmpTable )修改了記錄,自動將 A 列標記爲 M:

Private Sub Workbook_SheetChange(ByVal Sh As Object, ByVal Target As Range)
    If isRecordingChange = False Then Exit Sub

    Dim cell As Range
    Dim actionMarkCell As Range

    For Each cell In Target.Cells
        If isCellInRange(cell, SheetCRUD.ListObjects("EmpTable").DataBodyRange) Then
            Set actionMarkCell = SheetCRUD.Cells(cell.row, 1)
            If Len(actionMarkCell.Value) = 0 Then

                Call removeWorkSheetProtection(SheetCRUD)
                actionMarkCell.Value = "M"
                Call setWorksheetProtection(SheetCRUD)

            End If
        End If
    Next
End Sub

注意 isCellInRange(cell, SheetCRUD.ListObjects("EmpTable").DataBodyRange) 用於判斷數據修改過的單元格是否在 EmpTableDataBodyRange 範圍內。isCellInRange 是一個自定義函數, 判斷單元格 (cell) 是否在某一個範圍 (rng) 內。代碼如下:

Public Function isCellInRange(cell As Range, rng As Range) As Boolean    
    If rng Is Nothing Then
        isCellInRange = False
        Exit Function
    End If

    If cell Is Nothing Then
        isCellInRange = False
        Exit Function
    End If

    Dim isect As Object
    Set isect = Application.Intersect(cell, rng)

    If isect Is Nothing Then
        isCellInRange = False
    Else
        isCellInRange = True
    End If
End Function

如果用戶點擊了插入新行超鏈接,則自動在 A 列標記 N:

Public Sub insert_new_row()
    Call setRecordingFlag(False)
    Call removeWorkSheetProtection(SheetCRUD)

    Dim tbl As ListObject
    Set tbl = SheetCRUD.ListObjects("EmpTable")
    tbl.ListRows.Add alwaysinsert:=True
    tbl.Range(tbl.ListRows.Count, 1).Offset(1, -1).Value = "N"

    Call setRecordingFlag(True)
    Call setWorksheetProtection(SheetCRUD)
End Sub

超鏈接與宏代碼綁定

如何用超鏈接來完成操作呢?我以 “刷新” 爲例,介紹相關步驟。首先,在 B1 單元中輸入刷新 ,然後右鍵,選擇 超鏈接。在下面的界面中,“鏈接到” 選擇本文檔中的位置,單元格引用輸入本身所在的單元格,“屏幕提示” 可以輸入一個更加清晰的提示,否則,當光標在這個單元格,Excel 顯示鏈接的目標地址。

接下來,進入 VBE 代碼編寫環境,Excel 對於工作薄和工作表,都有相應的 FollowHyperLink 事件。本示例只有一個工作表,所以我就在 Worksheet_FollowHyperLink 事件中編寫代碼:

Private Sub Worksheet_FollowHyperlink(ByVal Target As Hyperlink)
    Dim rng As Range
    Set rng = Target.Range

    Select Case rng.Value
        Case "刷新"
            Call refresh_data

        Case "插入新行"
            Call insert_new_row

        Case "提交修改"
            Call submit_change_requests
    End Select
End Sub

根據 Target.Value,執行不同的操作。

前臺功能的實現

刷新數據

當用戶點擊刷新數據 按按鈕,觸發 refresh_data 過程。refresh_data 過程調用 get_employees() 函數:

Public Sub refresh_data()
    Call setRecordingFlag(False)
    Call removeWorkSheetProtection(SheetCRUD)

    Dim resp As HttpResponse
    resp = get_employees()
    If resp.Status = 200 Then
        Call writeJson(resp.ResponseText, SheetCRUD)
    End If

    setRecordingFlag True

    Call setWorksheetProtection(SheetCRUD)
End Sub

如果 Http 請求的狀態碼爲 200,將獲取的 json 數據寫到工作表中 (writeJson):

Public Sub writeJson(jsonText As String, sht As Worksheet)
    Dim parsedDict As Object
    Set parsedDict = JsonConverter.parseJson(jsonText)("rows")

    Dim tbl As ListObject
    Set tbl = sht.ListObjects("EmpTable")
    If Not tbl.DataBodyRange Is Nothing Then
        tbl.DataBodyRange.Rows.Delete
    End If

    ' Print headers
    Dim startCell As Range
    Set startCell = sht.Range("B2")

    startCell.Offset(0, 0) = "僱員ID"
    startCell.Offset(0, 1) = "性別"
    startCell.Offset(0, 2) = "年齡"
    startCell.Offset(0, 3) = "Email"
    startCell.Offset(0, 4) = "電話號碼"
    startCell.Offset(0, 5) = "教育程度"
    startCell.Offset(0, 6) = "婚姻狀況"
    startCell.Offset(0, 7) = "子女數"

    ' Print items
    Dim item As Dictionary
    Dim valArray() As Variant
    ReDim valArray(1 To parsedDict.Count, 1 To 8)

    Dim rowIdx As Long
    rowIdx = 1
    For Each item In parsedDict
        valArray(rowIdx, 1) = item("EMP_ID")
        valArray(rowIdx, 2) = item("GENDER")
        valArray(rowIdx, 3) = item("AGE")
        valArray(rowIdx, 4) = item("EMAIL")
        valArray(rowIdx, 5) = item("PHONE_NR")
        valArray(rowIdx, 6) = item("EDUCATION")
        valArray(rowIdx, 7) = item("MARITAL_STAT")
        valArray(rowIdx, 8) = item("NR_OF_CHILDREN")

        rowIdx = rowIdx + 1
    Next

    startCell.Offset(1, 0).Resize(parsedDict.Count, 8).Value = valArray
End Sub

插入新行

用戶點擊插入新行超鏈接,插入一個新行,並且標記爲 N。insert_new_row 的代碼剛剛已經介紹了,請自行參考。

提交修改

如果用戶點擊了提交修改超鏈接,自動將修改的數據提交到後臺:

Public Sub submit_change_requests()
    Dim empId As Integer
    Dim tbl As ListObject

    Set tbl = SheetCRUD.ListObjects("EmpTable")

    ' 取消工作表保護
    Call removeWorkSheetProtection(SheetCRUD)

    ' 根據 A 列確定相應的操作
    ' N: 新增, M: 修改, D: 刪除
    Dim idx As Long
    Dim action As String

    For idx = 1 To tbl.ListRows.Count
        action = tbl.ListRows(idx).Range(1, 1).Offset(0, -1).Value

        If UCase(action) = "N" Then
            If str(tbl.ListRows(idx).Range(1, 1).Value) = "" Then
                tbl.ListRows(idx).Range(1, 1).Offset(0, -1).Value = ""
            Else
                Dim newEmp As Employee
                Dim payload As String

                newEmp = build_employee_from_range(idx)
                payload = convert_emp_to_json_text(newEmp)

                Dim resp As HttpResponse
                resp = create_employee(payload)

                If resp.Status = 201 Then
                    tbl.ListRows(idx).Range(1, 1).Offset(0, -1).Value = ""
                End If
            End If
        End If

        If UCase(action) = "M" Then
            Application.ScreenUpdating = False

            Dim modifiedEmp As Employee
            modifiedEmp = build_employee_from_range(idx)
            empId = tbl.ListRows(idx).Range(1, 1).Value

            payload = convert_emp_to_json_text(modifiedEmp)
            Call modify_employee(empId, payload)

            tbl.ListRows(idx).Range(1, 1).Offset(0, -1).Value = ""
            Application.ScreenUpdating = True
        End If

        If UCase(action) = "D" Then
            empId = tbl.ListRows(idx).Range(1, 1).Value

            Call delete_employee_by_id(empId)

            tbl.ListRows(idx).Range(1, 1).Offset(0, -1).Value = ""
            tbl.ListRows(idx).Delete
        End If
    Next

    Call setWorksheetProtection(SheetCRUD)
End Sub

除了前面已經說明過的 create_employee() 函數和 modify_employee() 函數,submit_change_requests 過程還調用了 build_employee_from_range 函數,該函數將 Excel 某一行的數據轉換成 Dictionary 對象,convert_emp_to_json_text 函數,將 Dictionary 轉換成 json 格式。

因爲我們處理的數據都是針對 employee master,爲了方便,先定義一個結構:

Public Type Employee
    Emp_ID As Integer
    Gender As String
    Age As Integer
    Email As String
    Phone_Nr As String
    Education As String
    Marital_Stat As String
    Nr_Of_Children As Integer
End Type

build_employee_from_range 函數:

Public Function build_employee_from_range(rowNumber As Long) As Employee
    Dim tbl As ListObject
    Set tbl = SheetCRUD.ListObjects("EmpTable")

    Dim emp As Employee
    Dim idx As Long
    idx = rowNumber
    emp.Emp_ID = tbl.DataBodyRange(idx, 1).Value
    emp.Gender = tbl.DataBodyRange(idx, 2).Value
    emp.Age = tbl.DataBodyRange(idx, 3).Value
    emp.Email = tbl.DataBodyRange(idx, 4).Value
    emp.Phone_Nr = tbl.DataBodyRange(idx, 5).Value
    emp.Education = tbl.DataBodyRange(idx, 6).Value
    emp.Marital_Stat = tbl.DataBodyRange(idx, 7).Value
    emp.Nr_Of_Children = tbl.DataBodyRange(idx, 8).Value

    build_employee_from_range = emp
End Function

convert_emp_to_json_text 函數:

Public Function convert_emp_to_json_text(emp As Employee) As String
    Dim payloadDict As New Dictionary

    payloadDict.Add "EMP_ID", emp.Emp_ID
    payloadDict.Add "GENDER", emp.Gender
    payloadDict.Add "AGE", emp.Age
    payloadDict.Add "EMAIL", emp.Email
    payloadDict.Add "PHONE_NR", emp.Phone_Nr
    payloadDict.Add "EDUCATION", emp.Education
    payloadDict.Add "MARITAL_STAT", emp.Marital_Stat
    payloadDict.Add "NR_OF_CHILDREN", emp.Nr_Of_Children

    Dim payload As String
    payload = JsonConverter.ConvertToJson(payloadDict)

    convert_emp_to_json_text = payload
End Function
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章