需求
公司客户有需求,需要转换doc文件为pdf文件,并且保持格式完全不变
。
工程师用各种Java类库,无论是doc4j
、POI
还是Aspose.Doc
、Libreoffice
组件还是各种线上API服务,转换结果都不甚满意。
于是我这边接手这个活了。
调研
其实,最符合客户需求的莫过于原生Windows Office Word的导出功能了。
需要能够操作Windows的Office Word程序,那么需要能够直接访问其系统组件,需要类似COM
/OLE
系统库,说干就干。
- 1、运维做弄了一个配置比较低的EC2机器,windows10系统。
- 2、我这边找了一些库,python的comtypes.client,但是有点问题,单跑没问题,做成服务,在web线程中做这个事情,就有问题,具体找了下,应该还是线程问题,想了想,不做了(因为本身就不想用python写,😃 )
- 3、赶紧找了下golang中对应的OLE库,找到了一个,看了下文档,直接写了出来。
实现
话不多说,直接上核心代码看看:
下面是基础的解析过程,其实就是模拟以下四个步骤:
- 1、打开Office对应的程序(Word/Excel/PPT)
- 2、导出为PDF文件
- 3、关闭文件
- 4、退出Office程序
基础逻辑
package office
import (
ole "github.com/go-ole/go-ole"
"github.com/go-ole/go-ole/oleutil"
log "github.com/sirupsen/logrus"
)
/// 更多内容请参考官方COM文档 https://docs.microsoft.com/zh-cn/office/vba/api/word.application
type Operation struct {
OpType string
Arguments []interface{}
}
/// 部分应用不允许隐藏 ,比如ppt,所以Visible需要设定下
type ConvertHandler struct {
FileInPath string
FileOutPath string
ApplicationName string
WorkspaceName string
Visible bool
DisplayAlerts int
OpenFileOp Operation
ExportOp Operation
CloseOp Operation
QuitOp Operation
}
type DomConvertObject struct {
Application *ole.IDispatch
Workspace *ole.IDispatch
SingleFile *ole.IDispatch
}
func (handler ConvertHandler) Convert() {
ole.CoInitialize(0)
defer ole.CoUninitialize()
log.Println("handle open start")
dom := handler.Open()
log.Println("handle open end")
log.Println("handler in file path is " + handler.FileInPath)
log.Println("handler out file path is " + handler.FileOutPath)
defer dom.Application.Release()
defer dom.Workspace.Release()
defer dom.SingleFile.Release()
handler.Export(dom)
log.Println("handle export end")
handler.Close(dom)
log.Println("handle close end")
handler.Quit(dom)
log.Println("handle quit end")
}
func (handler ConvertHandler) Open() DomConvertObject {
var dom DomConvertObject
unknown, err := oleutil.CreateObject(handler.ApplicationName)
if err != nil {
panic(err)
}
dom.Application = unknown.MustQueryInterface(ole.IID_IDispatch)
oleutil.MustPutProperty(dom.Application, "Visible", handler.Visible)
oleutil.MustPutProperty(dom.Application, "DisplayAlerts", handler.DisplayAlerts)
dom.Workspace = oleutil.MustGetProperty(dom.Application, handler.WorkspaceName).ToIDispatch()
dom.SingleFile = oleutil.MustCallMethod(dom.Workspace, handler.OpenFileOp.OpType, handler.OpenFileOp.Arguments...).ToIDispatch()
return dom
}
func (handler ConvertHandler) Export(dom DomConvertObject) {
oleutil.MustCallMethod(dom.SingleFile, handler.ExportOp.OpType, handler.ExportOp.Arguments...)
}
func (handler ConvertHandler) Close(dom DomConvertObject) {
if handler.ApplicationName == "PowerPoint.Application" {
oleutil.MustCallMethod(dom.SingleFile, handler.CloseOp.OpType, handler.CloseOp.Arguments...)
} else {
oleutil.MustCallMethod(dom.Workspace, handler.CloseOp.OpType, handler.CloseOp.Arguments...)
}
}
func (handler ConvertHandler) Quit(dom DomConvertObject) {
oleutil.MustCallMethod(dom.Application, handler.QuitOp.OpType, handler.QuitOp.Arguments...)
不同格式的适配
支持Word/Excel/PPT转pdf,下面是Word转pdf的代码:
package office
func ConvertDoc2Pdf(fileInputPath string, fileOutputPath string) {
openArgs := []interface{}{fileInputPath}
/// https://docs.microsoft.com/zh-cn/office/vba/api/word.document.exportasfixedformat
exportArgs := []interface{}{fileOutputPath, 17}
closeArgs := []interface{}{}
quitArgs := []interface{}{}
convertHandler := ConvertHandler{
FileInPath: fileInputPath,
FileOutPath: fileOutputPath,
ApplicationName: "Word.Application",
WorkspaceName: "Documents",
Visible: false,
DisplayAlerts: 0,
OpenFileOp: Operation{
OpType: "Open",
Arguments: openArgs,
},
ExportOp: Operation{
OpType: "ExportAsFixedFormat",
Arguments: exportArgs,
},
CloseOp: Operation{
OpType: "Close",
Arguments: closeArgs,
},
QuitOp: Operation{
OpType: "Quit",
Arguments: quitArgs,
},
}
convertHandler.Convert()
}
提供web service接口
package web
import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"office-convert/office"
"os"
"path"
"path/filepath"
"runtime/debug"
"strconv"
log "github.com/sirupsen/logrus"
)
const PORT = 10000
const SAVED_DIR = "files"
type ConvertRequestInfo struct {
FileInUrl string `json:"file_in_url"`
SourceType string `json:"source_type"`
TargetType string `json:"target_type"`
}
func logStackTrace(err ...interface{}) {
log.Println(err)
stack := string(debug.Stack())
log.Println(stack)
}
func convertHandler(w http.ResponseWriter, r *http.Request) {
defer func() {
if r := recover(); r != nil {
w.WriteHeader(503)
fmt.Fprintln(w, r)
logStackTrace(r)
}
}()
if r.Method != "POST" {
w.WriteHeader(400)
fmt.Fprintf(w, "Method not support")
return
}
var convertRequestInfo ConvertRequestInfo
reqBody, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Println(err)
}
json.Unmarshal(reqBody, &convertRequestInfo)
log.Println(convertRequestInfo)
log.Println(convertRequestInfo.FileInUrl)
downloadFile(convertRequestInfo.FileInUrl)
fileOutAbsPath := getFileOutAbsPath(convertRequestInfo.FileInUrl, convertRequestInfo.TargetType)
convert(convertRequestInfo)
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/octet-stream")
//文件过大的话考虑使用io.Copy进行流式拷贝
outFileBytes, err := ioutil.ReadFile(fileOutAbsPath)
if err != nil {
panic(err)
}
w.Write(outFileBytes)
}
func convert(convertRequestInfo ConvertRequestInfo) {
fileOutAbsPath := getFileOutAbsPath(convertRequestInfo.FileInUrl, convertRequestInfo.TargetType)
switch convertRequestInfo.SourceType {
case "doc", "docx":
office.ConvertDoc2Pdf(getFileInAbsPath(convertRequestInfo.FileInUrl), fileOutAbsPath)
break
case "xls", "xlsx":
office.ConvertXsl2Pdf(getFileInAbsPath(convertRequestInfo.FileInUrl), fileOutAbsPath)
break
case "ppt", "pptx":
office.ConvertPPT2Pdf(getFileInAbsPath(convertRequestInfo.FileInUrl), fileOutAbsPath)
break
}
}
func getNameFromUrl(inputUrl string) string {
u, err := url.Parse(inputUrl)
if err != nil {
panic(err)
}
return path.Base(u.Path)
}
func getCurrentWorkDirectory() string {
cwd, err := os.Getwd()
if err != nil {
panic(err)
}
return cwd
}
func getFileInAbsPath(url string) string {
fileName := getNameFromUrl(url)
currentWorkDirectory := getCurrentWorkDirectory()
absPath := filepath.Join(currentWorkDirectory, SAVED_DIR, fileName)
return absPath
}
func getFileOutAbsPath(fileInUrl string, targetType string) string {
return getFileInAbsPath(fileInUrl) + "." + targetType
}
func downloadFile(url string) {
log.Println("Start download file url :", url)
resp, err := http.Get(url)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fileInAbsPath := getFileInAbsPath(url)
dir := filepath.Dir(fileInAbsPath)
// log.Println("dir is " + dir)
if _, err := os.Stat(dir); os.IsNotExist(err) {
log.Println("dir is not exists")
os.MkdirAll(dir, 0644)
}
out, err := os.Create(fileInAbsPath)
log.Println("save file to " + fileInAbsPath)
if err != nil {
panic(err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
if err != nil {
panic(err)
}
log.Println("Download file end url :", url)
}
func StartServer() {
log.Println("start service ...")
http.HandleFunc("/convert", convertHandler)
http.ListenAndServe("127.0.0.1:"+strconv.Itoa(PORT), nil)
}
部署/使用
编译 (可跳过)
如果要编译源码,得到exe文件,可以执行命令go build -ldflags "-H windowsgui"
生成 office-convert.exe
。不想编译的话,可以在prebuilt下找到对应exe文件。
运行
方法一:普通运行
- 双击执行
office-convert.exe
即可,但是如果程序报错,或者电脑异常关机,不会重启
方法二:后台运行(定时任务启动,可以自动恢复)
windows要做到定时启动/自动恢复,还挺麻烦的。。。
- 1、复制文件
将prebuilt
下两个文件复制到 C:\Users\Administrator\OfficeConvert\
目录下
- 2、修改COM访问权限
当我们以服务、定时任务启动程序的时候,会报错,提示空指针错误。
原因就是微软限制
了COM组件在非UI Session
的情况下使用(防止恶意病毒之类),如果要允许,需要做如下处理:
参考这里
- Open Component Services (Start -> Run, type in dcomcnfg)
- Drill down to Component Services -> Computers -> My Computer and click on DCOM Config
- Right-click on Microsoft Excel Application and choose Properties
- In the Identity tab select This User and enter the ID and password of an interactive user account (domain or local) and click Ok
注意,上图是演示,账号密码填写该机器的Administrator
账号密码
- 3、定时任务
创建windows定时任务,每1分钟调用check_start.bat
文件,该文件自动检查office-convert.exe
是否运行,没有就启动。
注意: 上图只是演示,具体位置填写 C:\Users\Administrator\OfficeConvert\check_start.bat
Web部署
使用nginx作为反向代理,具体位置在 C:\Users\Administrator\nginx-1.20.2\nginx-1.20.2
下,修改conf/nginx.conf
文件,代理127.0.0.1:10000即可,
有公网IP(比如xxx.com)的话,配置DNS解析convert-tools.xxx.com
到此机器ip。
server {
listen 80;
server_name convert-tools.xxx.net;
#charset koi8-r;
#access_log logs/host.access.log main;
location / {
root html;
index index.html index.htm;
proxy_pass http://127.0.0.1:10000;
}
# ...其他设置
}
请求
已部署到Windows机器,访问URL:
http://127.0.0.1:10000 (如果上面配置了域名,则访问 http://convert-tools.xxx.com/convert)
请求相关
Method : POST
Content-Type: application/json
Body:
{
"file_in_url":"https://your_docx_file_url",
"source_type":"docx",
"target_type":"pdf"
}
参数 | 是否必须 | 取值范围 | 说明 |
---|---|---|---|
file_in_url | 是 | 满足下面source_type的各类文档url | 待转换的文档的网络连接 |
source_type | 是 | [doc,docx,xls,xlsx,ppt,pptx] | 文档类型 |
target_type | 是 | 暂时只支持PDF,后续会支持更多 |
响应
根据HTTP状态码做判断
200 : ok
其他: 有错
Body:
转换的文件的二进制流
如果status_code非200,是对应的报错信息
评论区