GoFrame v2 + Vue 3 全栈应用示例:实现 docx 文件生成及下载
Update
- 20230419:补充 4.1 配置路由和 CORS 中间件。
- 20230815:修复docx生成时句子前后空格丢失的问题。repo代码已更新。
一、概述
从第134期(20230409)起,趋势周刊实现半自动化生成了!所谓半自动化,是这样一个流程:系统每周日下午2点定时生成“观星者计数TOP100”、“提交数TOP100”各 100 条数据,编者根据规则从这些记录中各选出10条,并在这20条记录中选择出2条分别设置为“星推荐”、“星关注”。之后对需要编辑、翻译的 repos 进行处理,最后自动生成文章内容和可供公众号导入的 docx 文件。
这是早就想解决的问题!原来采取纯手工编辑的方式需要在多个编辑器、浏览器页面之间反复切换,实在是考验人的耐性,对 GitHub 网站的访问更是让人无力吐槽,访问一个 repo 有时需要反复刷新页面 N 次——这时候的时间是数着秒度过的,等待着页面的出现、时时让你等到怀疑人生:)……再怎么样,100多期也是坚持做下来了。这次咬咬牙抽时间把周刊生成模块开发出来了,效果还是明显的:原来采取手工 SQL 查询、手工 HTML 文本和 docx 文件编辑发布的方式要花费约半个工作日的时间,现在不到1个小时即可完成。
二、需求分析
1、存在的问题、难点
言归正传。docx 文件生成是开发中常见的需求,由于前后端编程语言、文件编码之间的差异,用 Vue 实现 docx 文件下载比较容易出问题!最近,在作者这里就出过问题:由于客户端未能正确地解码文件名,生成了一个默认文件名的文件,作者用这个文件导入到公众号平台之后,当期的文章标题被替换成为了这个错误的文件名,然后就这么发布了。趋势周刊的标题长度,但凡看过其中一期的读者都知道,所以这标题——肯定是改不了了,“翻车现场”就在 Worldlink 公众号!尴尬!
开发过程的难点在于 docx 文件生成和客户端提交请求后自动下载上,请知悉你并不是在访问一个网盘或者是 FTP 服务器:)。从网上搜索的情况来看,相关的内容多为一些博主的笔记,以 Vue 端的为多,一些解决方法说到点子上了,但却并不一定适合自己的开发环境。因此,在这里根据在周刊模块开发中“踩坑”的解决经验,用 GoFrame v2 和 Vue 3 构建了一个简单的全栈项目,设计目标是设计尽量简化,这样读者能看得更清楚些。在这里把周刊的生成过程进行了简化,省略了数据库、UI 界面编写等操作过程。项目用各自官方的项目脚手架工具来实现,希望能帮你避坑、对你有所帮助。当然,这是一个全新的演示项目,与 WorldLink 趋势周刊生成模块在线版本并无相关。项目 GitHub repo:https://github.com/vulcangz/docxdownload。
简化后的需求是这样的:客户端提交生成第几期(issueNo)文章的需求。后端接收到请求后,生成一个文件名包含这个 issueNo 的 docx 文件,文件内容包括 Word 段落、文字大小、颜色设置、链接等;然后以 Blob 流的格式回传给客户端。客户端收到响应后,如状态正常,从响应标头中提取文件名,自动下载保存 .docx 文件;否则显示错误信息。
2、先决条件
1)知识准备
- 了解 GoFrame v2 框架设计体系;掌握其 Web 服务开发相关知识。
- 了解 Vue 3 开发相关知识(这个很简单,熟悉 Vue 2 的也可以的)。
2)开发环境
- Golang 开发环境
- Vue 3: 已安装 16.0 或更高版本的 Node.js
- IDE:VS Code(可选)
三、项目开发实施 STEP BY STEP
1、项目骨架初始化
首先把项目骨架搭起来,并保证各自能独立正常运行。这其中开发环境的安装过程就省略了,具体请参考官方文档(以下有提供官方链接)。
请注意:GoFrame 目前有2个版本。要调试运行这个项目,您只需要安装 v2 版本的客户端开发工具。
1)项目后端初始化
参考:https://goframe.org/pages/viewpage.action?pageId=59864669
D:\working\vulcangz>gf init docxdownload
initializing...
initialization done!
you can now run "cd docxdownload && gf run main.go" to start your journey, enjoy!
D:\working\vulcangz>cd docxdownload
D:\working\vulcangz\docxdownload>gf run main.go
build: main.go
go build -o ./\main.exe main.go
./\main.exe
build running pid: 8748
2023-04-18 11:58:19.636 [INFO] pid[8748]: http server started listening on [:8000]
2023-04-18 11:58:19.649 [INFO] swagger ui is serving at address: http://127.0.0.1:8000/swagger/
2023-04-18 11:58:19.659 [INFO] openapi specification is serving at address: http://127.0.0.1:8000/api.json
ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
----------|--------|------------|-----------------------------------------------------------------|----------------------------------
:8000 | ALL | /* | github.com/gogf/gf/v2/net/ghttp.internalMiddlewareServerTracing | GLOBAL MIDDLEWARE
----------|--------|------------|-----------------------------------------------------------------|----------------------------------
:8000 | ALL | /api.json | github.com/gogf/gf/v2/net/ghttp.(*Server).openapiSpec |
----------|--------|------------|-----------------------------------------------------------------|----------------------------------
:8000 | GET | /hello | docxdownload/internal/controller/hello.(*Controller).Hello | cmd.MiddlewareCORS
| | | | ghttp.MiddlewareHandlerResponse
----------|--------|------------|-----------------------------------------------------------------|----------------------------------
:8000 | ALL | /swagger/* | github.com/gogf/gf/v2/net/ghttp.(*Server).swaggerUI | HOOK_BEFORE_SERVE
----------|--------|------------|-----------------------------------------------------------------|----------------------------------
此时,正常情况下,在浏览器中访问 http://127.0.0.1:8000/hello应该可以看到熟悉的 “Hello World!” 出现。
2)项目前端初始化
参考:
https://cn.vuejs.org/examples/#hello-world
D:\working\vulcangz\docxdownload>npm init vue@latest
Vue.js - The Progressive JavaScript Framework
√ Project name: ... ui
√ Add TypeScript? ... No / Yes
√ Add JSX Support? ... No / Yes
√ Add Vue Router for Single Page Application development? ... No / Yes
√ Add Pinia for state management? ... No / Yes
√ Add Vitest for Unit Testing? ... No / Yes
√ Add an End-to-End Testing Solution? » No
√ Add ESLint for code quality? ... No / Yes
√ Add Prettier for code formatting? ... No / Yes
Scaffolding project in D:\working\vulcangz\docxdownload\ui...
Done. Now run:
cd ui
npm install
npm run format
npm run dev
D:\working\vulcangz\docxdownload>cd ui
D:\working\vulcangz\docxdownload\ui>yarn
yarn install v1.22.18
info No lockfile found.
[1/4] Resolving packages...
warning vue > @vue/compiler-sfc > magic-string > sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Saved lockfile.
Done in 38.06s.
D:\working\vulcangz\docxdownload\ui>yarn dev
yarn run v1.22.18
$ vite
VITE v4.2.1 ready in 1310 ms
➜ Local: http://127.0.0.1:5173/
➜ Network: use --host to expose
➜ press h to show help
在浏览器访问 http://127.0.0.1:5173/。正常情况下,可以看到熟悉的 Vue 标志。
2、项目后端核心代码实现
前后端各自能独立正常运行了吗?!那好,现在进行下一步:首先要对项目目录进行一个规划,不然该建什么文件?哪个文件放到哪个目录呢?
1)目录规划
根据 GoFrame 框架建议的工程目录结构,我们规划如下:
1)logic 业务逻辑: 即 docx 文件生成代码, internal\logic\down
2)api 定义:对外提供服务的输入/输出数据结构定义,api\docx\v1
3)controller 接口处理:调用 service 并响应客户端,internal\controller\down
4)service 服务接口定义:用客户端开发工具生成,internal\service
所有目录都是相对根目录的路径(Windows)。
2)docx 文件生成逻辑
这里主要做一件事,即根据 issueNo 生成 docx 文件并保存到当前目录。并把文件名返回给 controller。
D:\working\vulcangz\docxdownload\internal\logic\down\down.go
package down
import (
"context"
"docxdownload/internal/service"
"fmt"
"github.com/gingfrederik/docx"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
type sDown struct{}
func NewDown() *sDown {
return &sDown{}
}
// 这段代码建议在 service 生成之后再加上。
func init() {
service.RegisterDown(NewDown())
}
// Create 趋势周刊生成
func (c *sDown) Create(ctx context.Context, issueNo uint) (filename string, err error) {
f := docx.NewFile()
// add new paragraph
para := f.AddParagraph()
// add text
para.AddText("test")
para.AddText("test font size").Size(22)
para.AddText("test color").Color("808080")
para.AddText("test font size and color").Size(22).Color("ff0000")
nextPara := f.AddParagraph()
nextPara.AddLink("worldlink", `https://www.worldlink.com.cn`)
nextPara = f.AddParagraph()
nextPara.AddText("Documentation").Size(22).Color("red")
nextPara = f.AddParagraph()
nextPara.AddText("Vue’s")
nextPara.AddText(" ")
nextPara.AddLink("official documentation", `https://vuejs.org/`)
nextPara.AddText(" ")
nextPara.AddText("provides you with all information you need to get started.")
t := gtime.Now().Format("Ymd")
filename = fmt.Sprintf("周刊第%d期(%s).docx", issueNo, t)
err = f.Save(filename)
if err != nil {
g.Log().Fatal(ctx, err)
}
return
}
3)service 服务接口生成
根据业务模块 Logic 生成 service 接口。
D:\working\vulcangz\docxdownload>gf gen service
generating service go file: internal/service\down.go
generating init go file: internal/logic\logic.go
gofmt go files in "internal/service"
done!
4)controller 控制器编写
a)在 controller 编写之前,先定义好 API 结构
D:\working\vulcangz\docxdownload\api\docx\v1\down.go
package v1
import (
"github.com/gogf/gf/v2/frame/g"
)
type DownReq struct {
g.Meta `path:"/down" tags:"Down" method:"post" summary:"Create and download docx file"`
IssueNo uint `json:"issueNo" in:"query" d:"100" v:"required#期号不能为空" dc:"期号,默认100"`
}
type DownRes struct {
g.Meta `mime:"text/html" example:"string"`
}
b)控制器代码
这里接收处理获取前端参数 issueNo 之后,调用 service(实际上是 Logic),根据 service 返回的文件名,构建 Response 标头及数据。
D:\working\vulcangz\docxdownload\internal\controller\down\down.go
package down
import (
"context"
v1 "docxdownload/api/docx/v1"
"docxdownload/internal/service"
"github.com/gogf/gf/v2/encoding/gurl"
"github.com/gogf/gf/v2/frame/g"
)
var Down = cDown{}
type cDown struct{}
func (c *cDown) Docx(ctx context.Context, req *v1.DownReq) (res *v1.DownRes, err error) {
r := g.RequestFromCtx(ctx)
if err := r.Parse(&req); err != nil {
g.Log().Fatal(ctx,err)
}
filename, err := service.Down().Create(ctx,req.IssueNo)
w := g.RequestFromCtx(ctx).Response
w.Header().Add("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
w.Header().Add("Content-Type", "application/octet-stream") // 默认让浏览器下载文件
w.Header().Add("Access-Control-Expose-Headers", "Content-Disposition")
w.Header().Add("Content-Disposition", "attachment; filename="+gurl.RawEncode(filename))
w.Header().Add("Content-Transfer-Encoding", "binary")
// g.Dump(gfile.TempDir(), f)
w.ServeFile(filename)
return
}
这里标头的设置是必须的,文件名的编码是关键所在,gurl.RawEncode(filename) 是以符合 RFC 3986 规范的方式将文件名 filename 进行了编码,这样前端用 decodeURIComponent 就可以正常解码(支持中文),不需要借助于其他库(比如 iconv-lite)。如果你所用的 Go 框架中没有这个函数,可以参考下 gurl.RawEncode 的实现,它只是在 "net/url" 库 url.QueryEscape 的基础上将所有 "+" 替换为了 "%20"。
需要注意的是,如果不在响应标头中增加 "Access-Control-Expose-Headers":"Content-Disposition" 的话,前端在响应头里是看不到 "Content-Disposition" 的,更别说去拿它的值了。
3、项目前端代码实现
只是为了演示效果,前端部分只是在 Vue 骨架网页上添加了一个 button 按钮,点击按钮即可提交生成请求。代码核心的部分是 handleDownload 方法,其中 axios 的请求参数中 responseType: 'blob' 是关键所在。许多 Vue 管理模板中统一包装了 axios 的请求处理函数,你很难找到地方把这个 responseType 响应类型参数加进去。如果实在找不到,你可以做类似下面这样的处理,如果你的后端需要认证的话,记得加上 token 参数。
D:\working\vulcangz\docxdownload\ui\src\components\HelloWorld.vue
<script setup>
import axios from 'axios';
defineProps({
msg: {
type: String,
required: true
}
})
const API_URL = "http://localhost:8000/down"
const handleDownload = () => {
axios({
method: 'post',
url: API_URL,
params: {
issueNo: 168,
},
responseType: 'blob'
}).then(resp => {
// console.log(resp)
if (resp.status === 200) {
alert('生成成功!将自动下载docx文件……');
let temp = resp.headers["content-disposition"]
.split(";")[1]
.split("=")[1];
console.log(temp)
let fileName = decodeURIComponent(temp);
console.log(fileName)
const blob = new Blob([resp.data]);
const link = document.createElement("a");
link.style.display = 'none';
link.href = URL.createObjectURL(blob);
link.download = fileName || '周刊word文件.docx'; //下载的文件名
link.style.display = 'none'
document.body.appendChild(link);
link.click();
document.body.removeChild(link); // link.remove()
} else {
alert(resp.msg);
}
});
}
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
You’ve successfully created a project with
<a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
<h3>
<button @click="handleDownload" style="color: red;">生成并下载.docx</button>
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>
4、前后端联调
进行到这里,还有1件事需要做,就是让后端可以在指定端点接收到前端发送过来的请求——也就是路由设置。同时要解决前后端跨域的问题,我们在后端来解决。
之后您就可以开始联调了。这是一个设计得尽量简化的演示,所以一般情况下不需要对配置做修改。
1)配置路由和 CORS 中间件
D:\working\golang\src\github.com\vulcangz\docxdownload\internal\cmd\cmd.go
package cmd
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gcmd"
"docxdownload/internal/controller/down"
"docxdownload/internal/controller/hello"
)
var (
Main = gcmd.Command{
Name: "main",
Usage: "main",
Brief: "start http server",
Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(MiddlewareCORS) // 增加跨域支持中间件
group.Middleware(ghttp.MiddlewareHandlerResponse)
group.Bind(
hello.New(),
down.Down.Docx, // 增加这条路由
)
})
s.Run()
return nil
},
}
)
// MiddlewareCORS 跨域支持中间件
func MiddlewareCORS(r *ghttp.Request) {
r.Response.CORSDefault()
r.Middleware.Next()
}
在你的电脑上开两个终端窗口,一个运行后端服务器,一个运行前端应用。如下所示:
2)启动后端服务
D:\working\vulcangz\docxdownload>go run main.go
2023-04-18 10:23:11.493 [DEBU] SetServerRoot path: D:\working\vulcangz\docxdownload\ui\dist
2023-04-18 10:23:16.763 [INFO] pid[12988]: http server started listening on [:8000]
2023-04-18 10:23:16.768 [INFO] swagger ui is serving at address: http://127.0.0.1:8000/swagger/
2023-04-18 10:23:16.773 [INFO] openapi specification is serving at address: http://127.0.0.1:8000/api.json
ADDRESS | METHOD | ROUTE | HANDLER | MIDDLEWARE
----------|--------|------------|-----------------------------------------------------------------|----------------------------------
:8000 | ALL | /* | github.com/gogf/gf/v2/net/ghttp.internalMiddlewareServerTracing | GLOBAL MIDDLEWARE
----------|--------|------------|-----------------------------------------------------------------|----------------------------------
:8000 | ALL | /api.json | github.com/gogf/gf/v2/net/ghttp.(*Server).openapiSpec |
----------|--------|------------|-----------------------------------------------------------------|----------------------------------
:8000 | POST | /down | docxdownload/internal/controller/down.(*cDown).Docx | cmd.MiddlewareCORS
| | | | ghttp.MiddlewareHandlerResponse
----------|--------|------------|-----------------------------------------------------------------|----------------------------------
:8000 | GET | /hello | docxdownload/internal/controller/hello.(*Controller).Hello | cmd.MiddlewareCORS
| | | | ghttp.MiddlewareHandlerResponse
----------|--------|------------|-----------------------------------------------------------------|----------------------------------
:8000 | ALL | /swagger/* | github.com/gogf/gf/v2/net/ghttp.(*Server).swaggerUI | HOOK_BEFORE_SERVE
----------|--------|------------|-----------------------------------------------------------------|----------------------------------
眼尖的读者可能看到日子信息的第一行,“2023-04-18 10:23:11.493 [DEBU] SetServerRoot path: D:\working\vulcangz\docxdownload\ui\dist”,这个一会儿再说一下。
3)在另一个窗口,启动前端应用
D:\working\vulcangz\docxdownload\ui>yarn dev
yarn run v1.22.18
$ vite
VITE v4.2.1 ready in 2299 ms
➜ Local: http://127.0.0.1:5173/
➜ Network: use --host to expose
➜ press h to show help
然后在浏览器访问 http://127.0.0.1:5173/
点击左侧 logo 旁边的按钮 “生成并下载.docx” 就可以测试了。过程中可以按 F12 开启开发者工具看 “网络” 请求响应情况和 “控制台” 的输出信息。
四、项目优化部署
大家看上面的联调过程,要分两部分分别运行,是不是略有不便。GoFrame 框架内置提供了资源打包和使用的方法,我们可以把前端目标代码打包到可执行文件中去,这样在部署的时候只需要上传一个可执行文件。
具体配置如下。
1、代码生成配置
D:\working\vulcangz\docxdownload\hack\config.yaml
gfcli:
build:
name: "docxdownload" # 编译后的可执行文件名称
#arch: "all" # 不填默认当前系统架构,可选:386,amd64,arm,all
#system: "all" # 不填默认当前系统平台,可选:linux,darwin,windows,all
mod: "none"
cgo: 0
packSrc: "resource,manifest,ui/dist"
version: "v1.0.0"
output: "./bin/docxdownload" # 可执行文件生成路径
extra: ""
注意在 packSrc 中加入 "ui/dist" 这个目录!
2、前端目标代码生成
D:\working\vulcangz\docxdownload\ui>yarn build
yarn run v1.22.18
$ vite build
vite v4.2.1 building for production...
✓ 66 modules transformed.
dist/assets/logo-277e0e97.svg 0.28 kB
dist/index.html 0.42 kB
dist/assets/index-eed4fbc2.css 3.68 kB │ gzip: 1.20 kB
dist/assets/index-836f8053.js 92.58 kB │ gzip: 36.41 kB
✓ built in 2.69s
Done in 3.58s.
3、主程序生成
D:\working\vulcangz\docxdownload>gf build
2023-04-18 11:29:40.096 gf pack resource,manifest,ui/dist internal/packed/build_pack_data.go --keepPath=true
path 'internal/packed/build_pack_data.go' is not empty, files might be overwrote, continue? [y/n]: y
2023-04-18 11:29:43.383 done!
2023-04-18 11:29:43.754 start building...
2023-04-18 11:29:43.755 go build -o ./bin/docxdownload.exe main.go
2023-04-18 11:29:53.755 done!
好了,现在可以执行打包后的主程序了!
D:\working\vulcangz\docxdownload>bin\docxdownload
输出信息同以上的 4.1 几乎是一样的,就差第一句。
现在,我们在系统配置 system 块下面加上 serverRoot: "/ui/dist" 这一句,添加之后完整的配置如下:
D:\working\vulcangz\docxdownload\manifest\config\config.yaml
server:
serverRoot: "/ui/dist"
address: ":8000"
openapiPath: "/api.json"
swaggerPath: "/swagger"
logger:
level : "all"
stdout: true
现在再来执行主程序,是不是与 4.1 一样了?!
D:\working\vulcangz\docxdownload>bin\docxdownload
此时访问 http://127.0.0.1:8000/ 就出现你在 Vue 客户端运行时的浏览器界面了。
三、总结
这是一个以简洁设计为目标的全栈应用。它简洁到连自己的 UI 都没有!但在无论多复杂的 Golang + Vue 应用系统中,其代码都是可以借鉴使用的。大道至简,作者正致力于做到这一点。若代码中有错误或可完善的地方,欢迎提 issue。
1、存在的问题
"github.com/gingfrederik/docx" 库在目前还不支持空格,但这是目前在 GitHub 上开源的、少有的以 MIT 许可发布的 docx 生成库了。从代码来看,结构已支持空格,只是缺少“添加支持前后空格的文本”的方法!等等吧,空格总是会有的:)。没有等来官方的更新,那就自己动手hack下。从更新后的代码可以看出,我只是增加一个一个 “AddTextWithSpace”方法。具体可以参考:https://github.com/osdir/docx。当然,您也可以选择使用其他库。
四、致谢
- GoFrame:https://github.com/gogf/gf
- Vue:https://github.com/vuejs/vue
- docx:https://github.com/gingfrederik/docx
- 沉默的鑫:前端下载二进制文件以及解决从content-disposition获取文件名中文乱码问题