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。当然,您也可以选择使用其他库。

四、致谢

 

Like:
0
To the top