Excel 也可以玩 REST (3)

系列文章索引

Excel 也可以玩 REST
Excel 也可以玩 REST (2)
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 可能有一定难度。

我的相关文章链接:

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

至此,后台功能全部完毕。

界面实现逻辑

下面说明前端的实现方式。首先我们看一看前端的界面,以及我的思路:

《Excel 也可以玩 REST (3)》

数据刷新、提交修改等功能,都通过 “超链接” 的方法实现,没有使用按钮控件,这样界面更加清爽。当用户在数据区域操作时,自动对用户所在行的状态进行记录。当用户修改了数据,所在行的 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 显示链接的目标地址。

《Excel 也可以玩 REST (3)》

接下来,进入 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
    原文作者:StoneWM
    原文地址: https://www.jianshu.com/p/0667d108e95d
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞