使用 Go 在单个二进制文件中提供单页应用程序
尽管部署单页应用程序(SPA)有很多种方法,但您可能会发现需要在隔离环境中部署它或仅仅出于可移植性的考虑。
因此,在本文中,我们将使用SvelteKit生成SPA(或者,您也可以使用任何流行的前端框架)并将其与Go一起嵌入到单个二进制文件中。
📦最终存储库托管在Github上。
目录
- 视频版
- 初始化 SvelteKit
- 配置适配器静态
- 添加页面路由
- 构建静态文件
- 初始化 Go
- 嵌入静态文件
- 使用 Go HTTP 库提供服务
- 处理未找到的路由
- 使用 Echo 框架提供服务
- 使用 Fiber 框架
- 使用 Docker 进行容器化
- 添加 Makefile
视频版
初始化 SvelteKit
请注意,这不是 SvelteKit 速成课程。因此我们只会添加一些基本功能,例如路由和获取 JSON 数据。
通过在终端运行命令为项目创建一个新文件夹:
mkdir go-embed-spa && cd go-embed-spa
要在前端文件夹内的项目目录根目录中搭建 SvelteKit,请运行以下命令:
npm init svelte@next frontend
为了简单起见,使用不带 Typescript、ESLint 和 Prettier 的骨架项目模板。
✔ Which Svelte app template? › Skeleton project
✔ Use TypeScript? … No
✔ Add ESLint for code linting? … No
✔ Add Prettier for code formatting? … No
初始项目结构:
go-embed-spa/
└── frontend/ # generated through `npm init svelte@next frontend`
配置适配器静态
SvelteKit 有许多用于构建应用程序的适配器。但在本例中,我们将使用静态适配器。因此,在package.json
“devDependencies”之间的文件中,将适配器从 替换adapter-auto
为adapter-static
。
- "@sveltejs/adapter-auto": "next", // delete this line
+ "@sveltejs/adapter-static": "next", // add this line
go-embed-spa/frontend/package.json
打开svelte.config.js
文件并替换adapter-auto
为,adapter-static
并将后备设置为index.html
import adapter from "@sveltejs/adapter-static";
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
fallback: "index.html", // for a pure single-page application mode
}),
},
};
export default config;
go-embed-spa/frontend/svelte.config.js
通过运行以下命令安装所有依赖项:
cd frontend && npm install
或者,--prefix
如果不想导航到前端文件夹,也可以使用关键字。
npm install --prefix ./frontend
添加页面路由
在frontend/src/routes
目录中添加一个名为的新文件,about.svelte
其内容如下:
<script>
const status = fetch("/hello.json")
.then((r) => r.json())
.catch((err) => {
throw new Error(err);
});
</script>
<svelte:head>
<title>About</title>
</svelte:head>
<h1>About</h1>
<p>
<strong>server respond</strong>:
{#await status}
loading
{:then data}
{data.message}
{:catch err}
failed to load data
{/await}
</p>
<p>This is about page</p>
<style>
h1 {
color: green;
}
</style>
go-embed-spa/frontend/src/routes/about.svelte
你会注意到有一个用于获取 JSON 数据的脚本。不用担心,我们稍后会用 Go 语言创建处理程序。
在文件中index.svelte
添加<svelte:head>
一个 title 标签。这样应用程序的标题就会出现在该<head>
标签中。为标题一添加一个样式,颜色为blueviolet。
<svelte:head>
<title>Homepage</title>
</svelte:head>
<h1>Welcome to SvelteKit</h1>
<p>
Visit <a href="https://kit.svelte.dev" rel="external">kit.svelte.dev</a> to
read the documentation
</p>
<style>
h1 {
color: blueviolet;
}
</style>
go-embed-spa/frontend/src/routes/about.svelte
添加 CSS 文件go-embed-spa/frontend/src/global.css
body {
background-color: aliceblue;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
接下来,添加__layout.svelte
。导入global.css
并添加导航。
<script>
import "../global.css";
</script>
<nav>
<a href=".">Home</a>
<a href="/about">About</a>
<a href="/404">404</a>
</nav>
<slot />
go-embed-spa/frontend/src/routes/__layout.svelte
最后一个将添加__error.svelte
包含以下内容的页面:
<script context="module">
export function load({ status, error }) {
return {
props: { status, error },
};
}
</script>
<script>
export let status, error;
</script>
<svelte:head>
<title>{status}</title>
</svelte:head>
<h1>{status}</h1>
<p>{error.message}</p>
<style>
h1 {
color: crimson;
}
</style>
go-embed-spa/frontend/src/routes/__error.svelte
构建静态文件
要生成静态文件,请运行以下命令:
npm run build
# or
npm run build --prefix ./frontend
# if not inside the frontend directory
运行构建命令已在构建目录中生成静态文件,稍后我们将使用 Go 嵌入并提供这些文件。
go-embed-spa/
└── frontend/
├── .svelte-kit/
├── build/ # generated from the build command
├── node_modules/
├── src/
└── static/
初始化 Go
要初始化 Go 模块,请在终端上运行以下命令:
go mod init github.com/${YOUR_USERNAME}/go-embed-spa
替换${YOUR_USERNAME}
为你的 Github 用户名。此命令将生成一个名为 的新文件go.mod
。
嵌入静态文件
frontend.go
在前端文件夹内创建一个名为的新文件。
package frontend
import (
"embed"
"io/fs"
"log"
"net/http"
)
// Embed the build directory from the frontend.
//go:embed build/*
//go:embed build/_app/immutable/pages/*
//go:embed build/_app/immutable/assets/pages/*
var BuildFs embed.FS
// Get the subtree of the embedded files with `build` directory as a root.
func BuildHTTPFS() http.FileSystem {
build, err := fs.Sub(BuildFs, "build")
if err != nil {
log.Fatal(err)
}
return http.FS(build)
}
go-embed-spa/frontend/frontend.go
关于 Go embed 指令的简要说明:
//go:embed
如果模式以 开头,我们就不能使用该指令../
。- 但幸运的是,我们可以导出嵌入变量。这就是为什么变量和函数名都以大写字母 开头的原因。
//go:embed
带有目录模式的指令将递归包含所有文件和子目录,但不包括以 a.
或开头的文件_
。因此,我们需要明确使用*
符号来包含它们。
io/fs库有一个Sub方法,可以获取嵌入文件的子树。因此,我们可以将build
目录用作根目录。
使用 Go HTTP 库提供服务
在以下位置创建一个名为main.go的新文件/go-embed-spa/cmd/http/main.go
package main
import (
"encoding/json"
"log"
"net/http"
"github.com/aprakasa/go-embed-spa/frontend"
)
func main() {
http.HandleFunc("/hello.json", handleHello)
http.HandleFunc("/", handleSPA)
log.Println("the server is listening to port 5050")
log.Fatal(http.ListenAndServe(":5050", nil))
}
func handleHello(w http.ResponseWriter, r *http.Request) {
res, err := json.Marshal(map[string]string{
"message": "hello from the server",
})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(res)
}
func handleSPA(w http.ResponseWriter, r *http.Request) {
http.FileServer(frontend.BuildHTTPFS()).ServeHTTP(w, r)
}
- 这
handleHello
是一个在路由上提供 JSON 的处理函数/hello.json
。 - 这
handleSPA
是一个用于处理嵌入的静态文件的处理函数。
此时,代码已经足够嵌入二进制文件并提供构建目录了。你可以运行以下命令来尝试:
go build ./cmd/http
尝试删除该build/
目录以验证它是否已嵌入。然后运行二进制文件。
./http
# or
./http.exe
# for windows user
打开浏览器并导航至http://localhost:5050
。
处理未找到的路由
不幸的是,如果我们直接访问非根路径,服务器将发送 404 错误(未找到)。处理路由的逻辑来自客户端,这是单页应用程序的基本行为。
我们可以通过比较请求的 URL 路径和嵌入的构建目录中的文件来解决路由未找到的问题。因此,如果根据请求的 URL 路径没有匹配的文件,服务器端将发送该文件。index.html
处理未找到路线的最终 Go 代码:
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"path/filepath"
"github.com/aprakasa/go-embed-spa/frontend"
)
func main() {
http.HandleFunc("/hello.json", handleHello)
http.HandleFunc("/", handleSPA)
log.Println("the server is listening to port 5050")
log.Fatal(http.ListenAndServe(":5050", nil))
}
func handleHello(w http.ResponseWriter, r *http.Request) {
res, err := json.Marshal(map[string]string{
"message": "hello from the server",
})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(res)
}
func handleSPA(w http.ResponseWriter, r *http.Request) {
buildPath := "build"
f, err := frontend.BuildFs.Open(filepath.Join(buildPath, r.URL.Path))
if os.IsNotExist(err) {
index, err := frontend.BuildFs.ReadFile(filepath.Join(buildPath, "index.html"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusAccepted)
w.Write(index)
return
} else if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
http.FileServer(frontend.BuildHTTPFS()).ServeHTTP(w, r)
}
现在我们可以通过运行以下命令重建静态文件和二进制文件:
npm run build --prefix ./frontend && go build ./cmd/http
使用以下二进制文件运行应用程序:
./http
尝试在浏览器中直接访问https://localhost:5050/about
未知路由器。如果所有设置正确,则“未找到”将由客户端处理。
使用 Echo 框架提供服务
在下方添加新文件go-embed-spa/cmd/echo/main.go
package main
import (
"log"
"net/http"
"github.com/aprakasa/go-embed-spa/frontend"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
app := echo.New()
app.GET("/hello.json", handleHello)
app.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Filesystem: frontend.BuildHTTPFS(),
HTML5: true,
}))
log.Fatal(app.Start(":5050"))
}
func handleHello(c echo.Context) error {
return c.JSON(http.StatusOK, echo.Map{
"message": "hello from the echo server",
})
}
使用Echo 框架处理未找到的路由和创建 API端点更加简单。只需在静态配置中间件中将HTML5设置为true即可。
使用 Fiber 框架
在下方添加新文件go-embed-spa/cmd/fiber/main.go
package main
import (
"fmt"
"log"
"os"
"github.com/aprakasa/go-embed-spa/frontend"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/filesystem"
)
func main() {
app := fiber.New()
app.Get("/hello.json", handleHello)
app.Use("/", filesystem.New(filesystem.Config{
Root: frontend.BuildHTTPFS(),
NotFoundFile: "index.html",
}))
log.Fatal(app.Listen(fmt.Sprintf(":%s", os.Getenv("APP_PORT"))))
}
func handleHello(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"message": "hello from the fiber server"})
}
Fiber Framework还提供了无缝集成来处理未找到的路由,只需从中间件使用index.html配置NotFoundFile即可。filesystem
使用 Docker 进行容器化
为了简化部署过程,Dockerfile
请按照以下说明在项目目录中添加:
# syntax=docker/dockerfile:1.2
# Stage 1: Build the static files
FROM node:16.15.0-alpine3.15 as frontend-builder
WORKDIR /builder
COPY /frontend/package.json /frontend/package-lock.json ./
RUN npm ci
COPY /frontend .
RUN npm run build
# Stage 2: Build the binary
FROM golang:1.18.3-alpine3.15 as binary-builder
ARG APP_NAME=http
RUN apk update && apk upgrade && \
apk --update add git
WORKDIR /builder
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=frontend-builder /builder/build ./frontend/build/
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags='-w -s -extldflags "-static"' -a \
-o engine cmd/${APP_NAME}/main.go
# Stage 3: Run the binary
FROM gcr.io/distroless/static
ENV APP_PORT=5050
WORKDIR /app
COPY --from=binary-builder --chown=nonroot:nonroot /builder/engine .
EXPOSE $APP_PORT
ENTRYPOINT ["./engine"]
由于我们有三个入口点,我们可以使用ARG
指令设置自定义目录。假设APP_NAME
使用http
作为默认值。
我们还可以使用 的变量将应用程序端口设置为更加可定制,APP_PORT
并使用 来设置默认值5050
。
我们需要从 Go 入口点更新所有硬编码端口。
// go-embed-spa/cmd/http/main.go
log.Printf("the server is listening to port %s", os.Getenv("APP_PORT"))
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", os.Getenv("APP_PORT")), nil))
// go-embed-spa/cmd/echo/main.go
log.Fatal(app.Start(fmt.Sprintf(":%s", os.Getenv("APP_PORT"))))
// go-embed-spa/cmd/fiber/main.go
log.Fatal(app.Listen(fmt.Sprintf(":%s", os.Getenv("APP_PORT"))))
不要忘记导入os
库。
添加 Makefile
添加一个名为 Makefile 的新文件以缩短构建过程。
FRONTEND_DIR=frontend
BUILD_DIR=build
APP_NAME=http
APP_PORT=5050
clean:
cd $(FRONTEND_DIR); \
if [ -d $(BUILD_DIR) ] ; then rm -rf $(BUILD_DIR) ; fi
static: clean
cd $(FRONTEND_DIR); \
npm install; \
npm run build
build: clean
DOCKER_BUILDKIT=1 docker build -t spa:$(APP_NAME) --build-arg APP_NAME=$(APP_NAME) .
run:
docker run -dp $(APP_PORT):$(APP_PORT) --name spa-$(APP_NAME) -e APP_PORT=$(APP_PORT) --restart unless-stopped spa:$(APP_NAME)
.PHONY: clean static
现在我们可以使用以下命令构建 docker 镜像:
make build # for net/http libary
make build APP_NAME=echo # for echo libary
make build APP_NAME=fiber # for fiber libary
要运行图像,我们可以运行以下命令:
make run # for net/http libary
make run APP_NAME=echo APP_PORT=5051 # for echo libary
make run APP_NAME=fiber APP_PORT=5052 # for fiber libary
结果,我们有一个与 HTTP 服务器相结合的微型单页应用程序。
链接地址:https://dev.to/aryaprakasa/serving-single-page-application-in-a-single-binary-file-with-go-12ij