使用 Go 在单个二进制文件中提供单页应用程序

2025-06-08

使用 Go 在单个二进制文件中提供单页应用程序

尽管部署单页应用程序(SPA)有很多种方法,但您可能会发现需要在隔离环境中部署它或仅仅出于可移植性的考虑。

因此,在本文中,我们将使用SvelteKit生成SPA(或者,您也可以使用任何流行的前端框架)并将其与Go一起嵌入到单个二进制文件中。

📦最终存储库托管在Github上。

目录

视频版

初始化 SvelteKit

请注意,这不是 SvelteKit 速成课程。因此我们只会添加一些基本功能,例如路由和获取 JSON 数据。

通过在终端运行命令为项目创建一个新文件夹:

mkdir go-embed-spa && cd go-embed-spa
Enter fullscreen mode Exit fullscreen mode

要在前端文件夹内的项目目录根目录中搭建 SvelteKit,请运行以下命令:

npm init svelte@next frontend
Enter fullscreen mode Exit fullscreen mode

为了简单起见,使用不带 Typescript、ESLint 和 Prettier 的骨架项目模板。

✔ Which Svelte app template? › Skeleton project
✔ Use TypeScript? … No
✔ Add ESLint for code linting? … No
✔ Add Prettier for code formatting? … No
Enter fullscreen mode Exit fullscreen mode

初始项目结构:

go-embed-spa/
└── frontend/         # generated through `npm init svelte@next frontend`
Enter fullscreen mode Exit fullscreen mode

配置适配器静态

SvelteKit 有许多用于构建应用程序的适配器。但在本例中,我们将使用静态适配器。因此,在package.json“devDependencies”之间的文件中,将适配器从 替换adapter-autoadapter-static

- "@sveltejs/adapter-auto": "next", // delete this line
+ "@sveltejs/adapter-static": "next", // add this line
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

go-embed-spa/frontend/svelte.config.js

通过运行以下命令安装所有依赖项:

cd frontend && npm install
Enter fullscreen mode Exit fullscreen mode

或者,--prefix如果不想导航到前端文件夹,也可以使用关键字。

npm install --prefix ./frontend
Enter fullscreen mode Exit fullscreen mode

添加页面路由

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

接下来,添加__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 />
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

go-embed-spa/frontend/src/routes/__error.svelte

构建静态文件

要生成静态文件,请运行以下命令:

npm run build
# or
npm run build --prefix ./frontend
# if not inside the frontend directory
Enter fullscreen mode Exit fullscreen mode

运行构建命令已在构建目录中生成静态文件,稍后我们将使用 Go 嵌入并提供这些文件。

go-embed-spa/
└── frontend/
    ├── .svelte-kit/
    ├── build/          # generated from the build command
    ├── node_modules/
    ├── src/
    └── static/
Enter fullscreen mode Exit fullscreen mode

初始化 Go

要初始化 Go 模块,请在终端上运行以下命令:

go mod init github.com/${YOUR_USERNAME}/go-embed-spa
Enter fullscreen mode Exit fullscreen mode

替换${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)
}
Enter fullscreen mode Exit fullscreen mode

go-embed-spa/frontend/frontend.go

关于 Go embed 指令的简要说明:

  1. //go:embed如果模式以 开头,我们就不能使用该指令../
  2. 但幸运的是,我们可以导出嵌入变量。这就是为什么变量和函数名都以大写字母 开头的原因。
  3. //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)
}
Enter fullscreen mode Exit fullscreen mode
  1. handleHello是一个在路由上提供 JSON 的处理函数/hello.json
  2. handleSPA是一个用于处理嵌入的静态文件的处理函数。

此时,代码已经足够嵌入二进制文件并提供构建目录了。你可以运行以下命令来尝试:

go build ./cmd/http
Enter fullscreen mode Exit fullscreen mode

尝试删除该build/目录以验证它是否已嵌入。然后运行二进制文件。

./http
# or
./http.exe
# for windows user
Enter fullscreen mode Exit fullscreen mode

打开浏览器并导航至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)
}
Enter fullscreen mode Exit fullscreen mode

现在我们可以通过运行以下命令重建静态文件和二进制文件:

npm run build --prefix ./frontend && go build ./cmd/http
Enter fullscreen mode Exit fullscreen mode

使用以下二进制文件运行应用程序:

./http
Enter fullscreen mode Exit fullscreen mode

尝试在浏览器中直接访问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",
    })
}
Enter fullscreen mode Exit fullscreen mode

使用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"})
}
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

由于我们有三个入口点,我们可以使用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"))))
Enter fullscreen mode Exit fullscreen mode

不要忘记导入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
Enter fullscreen mode Exit fullscreen mode

现在我们可以使用以下命令构建 docker 镜像:

make build # for net/http libary
make build APP_NAME=echo # for echo libary
make build APP_NAME=fiber # for fiber libary
Enter fullscreen mode Exit fullscreen mode

要运行图像,我们可以运行以下命令:

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
Enter fullscreen mode Exit fullscreen mode

结果,我们有一个与 HTTP 服务器相结合的微型单页应用程序。

Docker 镜像

链接地址:https://dev.to/aryaprakasa/serving-single-page-application-in-a-single-binary-file-with-go-12ij
PREV
使用 i18next 实现 React 应用国际化
NEXT
使用 NestJS 搭建 Node.js 服务器,包含 TypeScript 和 GraphQL