Go를 사용하여 단일 바이너리 파일로 단일 페이지 애플리케이션 제공

49541 단어 sveltedockergo
SPA(단일 페이지 애플리케이션)를 배포하는 대안이 많이 있지만 격리된 환경이나 이식성 문제에 배포해야 하는 상황을 찾을 수 있습니다.

따라서 이 기사에서는 SvelteKit을 사용하여 SPA를 생성하고(또는 인기 있는 프런트 엔드 프레임워크를 사용할 수도 있음) embedGo과 함께 단일 이진 파일로 생성합니다.

📦 최종 저장소는 Github에서 호스팅됩니다.

목차


  • Video Version
  • Initialize SvelteKit
  • Configure Adapter Static
  • Add Page Routes
  • Build The Static Files
  • Initialize Go
  • Embed The Static Files
  • Serve with Go HTTP Library
  • Handling The Not-Found Route
  • Serve with Echo Framework
  • Serve with Fiber Framework
  • Containerize with Docker
  • Add Makefile

  • 비디오 버전

    SvelteKit 초기화

    Please be advised that this is not a SvelteKit crash course. So we'll only add some basic functionality such as routing and fetching JSON data.

    Create a new folder for the project by running the command at the terminal:

    mkdir go-embed-spa && cd go-embed-spa
    
    To scaffold SvelteKit frontend 폴더 내 프로젝트 디렉토리의 루트 내부에서 아래 명령을 실행합니다.

    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 has many adapters 응용 프로그램을 빌드합니다. 하지만 이 경우에는 static adapter 을 사용합니다. 따라서 "devDependencies"사이의 package.json 파일에서 어댑터를 adapter-auto에서 adapter-static로 교체합니다.

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

    go-embed-spa/frontend/package.jsonsvelte.config.js 파일을 열고 adapter-autoadapter-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
    


    페이지 경로 추가

    At the frontend/src/routes directory add a new file called about.svelte with a content below:

    <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

    You'll notice there is a script to fetch JSON data. No worry, we'll create the handler in Go later.

    At the index.svelte file, add <svelte:head> with a title tag. So the application will have a title in the <head> tag. Add a style for heading one with a color of 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

    Add a CSS file at 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;
    }
    

    Next, add __layout.svelte . Import the global.css and add a navigation.

    <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

    The last one will be adding the __error.svelte page with the content below:

    <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

    정적 파일 빌드

    To generate the static files, run this command below:

    npm run build
    # or
    npm run build --prefix ./frontend
    # if not inside the frontend directory
    

    The run build command has generated static files inside the build directory, which we'll embed and serve with Go later.

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

    Go 초기화

    To initialize the Go module, run the command on the terminal:

    go mod init github.com/${YOUR_USERNAME}/go-embed-spa
    

    Replace ${YOUR_USERNAME} with your Github username. This command will generate a new file called go.mod .

    정적 파일 포함

    Create a new file called frontend.go inside the frontend folder.

    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

    A quick notes about the Go embed directive:

  • We can't use the //go:embed directive if the pattern started with ../ .
  • But luckily we can export the embed variable. That's the reason why the variable and function name started with capital letter .
  • 디렉토리 패턴이 있는 //go:embed 지시어는 . 또는 _ 로 시작하는 파일 이름을 제외하고 모든 파일과 하위 디렉토리를 재귀적으로 포함합니다. 따라서 명시적으로 * 기호를 사용하여 포함해야 합니다.

  • io/fs 메서드를 사용하는 Sub 라이브러리는 포함된 파일의 하위 트리를 가져옵니다. 따라서 build 디렉토리를 루트로 사용할 수 있습니다.

    Go HTTP 라이브러리와 함께 제공

    Create a new file called main.go at /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)
    }
    
    1. The handleHello is a handler function to serve the JSON at the /hello.json route.
    2. The handleSPA is a handler function to serve the embedded static files.

    At this point, the code is enough to embed and serve the build directory from the binary. You can try it by running:

    go build ./cmd/http
    

    Try to delete the build/ directory to verify if it is embedded. And then run the binary.

    ./http
    # or
    ./http.exe
    # for windows user
    

    Open the browser and navigate to http://localhost:5050 .

    찾을 수 없는 경로 처리

    Unfortunately, if we direct access to the non-root path, the server will send a 404 error not found. The logic for handling the routes is from the client-side, which is the basic behavior for the single-page application.

    We can solve the not-found routes by comparing the requested URL path and the files inside the embedded build directory. So if there are no matching files based on the requested URL path, the server-side will send the index.html file.

    The final Go code to handle the not-found route:

    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)
    }
    

    Now we can rebuild the static files and the binary by running:

    npm run build --prefix ./frontend && go build ./cmd/http
    

    Run the application from the binary with:

    ./http
    

    Try to direct access to https://localhost:5050/about or the unknown router in the browser. If everything setup correctly, the the not-found will be handled by the client-side.

    Echo 프레임워크와 함께 제공

    Add a new file under 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",
        })
    }
    
    Handling the not-found route and creating API end-point is more simpler with Echo Framework . 정적 구성 미들웨어에서 HTML5에 대해 true로 설정하기만 하면 됩니다.

    Fiber 프레임워크와 함께 제공

    Add a new file under 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은 또한 filesystem 미들웨어에서 index.html로 NotFoundFile을 구성하여 찾을 수 없는 경로를 처리하기 위한 완벽한 통합을 제공합니다.

    Docker로 컨테이너화

    To streamline the deploy process, add a Dockerfile inside the project directory with the instructions below:

    # 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"]
    

    Since we have three entry points, we can use ARG instruction to set a custom directory. Let's say APP_NAME with http as the default value.

    We can also set the app port to be more customizable with a variable of APP_PORT and set the default value with 5050 .

    We'll need to update all the hardcoded ports from our Go entry points.

    // 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"))))
    

    Don't forget to import the os library.

    메이크파일 추가

    Add a new file called Makefile to shortend the build process.

    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
    

    Now we can build the docker image with:

    make build # for net/http libary
    make build APP_NAME=echo # for echo libary
    make build APP_NAME=fiber # for fiber libary
    

    To run the image we can run the command with:

    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
    

    As a result, we have a tiny little single-page application combined with the HTTP server.

    좋은 웹페이지 즐겨찾기