Introducing Pkger — Static File Embedding in Go
One of the reasons I fell in love with Go all those years ago was the idea that I could distribute a single binary that only contained my application, but also the supporting infrastructure needed to run that binary without any tooling installed. Coming from Ruby, a language where you primarily distribute an entire folder structure that requires runtime tooling, this was revolutionary for me, and immediately my mind buzzed with the possibilities.
Several years ago I was building an enterprise web product for a client. This project, as I’ve talked about before, eventually evolved in Buffalo. One of the requirements for Buffalo was to keep the promise of a single binary and this meant embedding static files in Go binaries.
As I searched around I found several tools that did just this. The one that caught my eye was go.Rice. What I liked most about it was that in would fall back to the file system during development meaning you didn’t have to keep re-embedding the files every time you made a change. Early versions of Buffalo shipped with go.Rice as its embedding tooling.
As early adopters started deploying the first Buffalo applications in production, bug reports started coming in with very issues regarding go.Rice. It appeared that, for a variety of reasons, we needed a new solution.
It was that Packr came to be. It adopted the go.Rice API for easy migration of tools. For the past few years Packr has played a large role in Buffalo’s increased popularity by helping to keep that one binary promise.
Unfortunately, Packr’s API and underlying architecture have proven to be both problematic and lossy. Important meta data such as mod time, mode, size, etc… are all lost when using Packr. This makes for a variety of bugs, but also limits what developers can do when using Packr instead of the standard library’s OS package to work with files. It was time to rethink the problem.
The Requirements
Before I thought of writing a line of code I considered the problems I had with Packr and how I could solve them. The solutions to these problems would become the requirements that I would need to work backward from.
The API
As I mentioned early Packr’s API was based on that of go.Rice, but that also meant losing important metadata since the API had no place for it. It was also not idiomatic. User’s have to learn a new API when using Packr. Consider the differences between the standard library filepath.Walk function and packr.Walk
.
// standard library
filepath.Walk(".", func(path string, info os.FileInfo, err error) error// Packr
box.Walk(func(path string, f packd.File) error
I wanted the API to feel like the standard library, and that meant adopting the standard library’s API.
Along with the benefits of an idiomatic API it also provides an immediate reference implementation.
Deterministic Pathing
There are many current, and many more closed, issues regarding Packr’s use of relative pathing and all of the fun ways that pathing can prove unreliable. The solution for this problem needed to provide for a static reference point regardless of where in the project the tools, or libraries, are being used.
The Go tooling has a built in solution to that problem that arrived with Modules.
$ go env GOMOD
$HOME/myapp/go.mod
The GOMOD
tells you where the go.mod file is for a project, regardless of how deep you may be in that project. To use this would provide a true North for any tool to be able to find it’s way back to the root of the project.
Using this known root I decided to use an absolute Unix style path with /
being the root of the project.
"./public"
"/public"
By removing the relative nature of the .
We no longer have to jump through hoops to resolve that dot.
Testing, Dependencies, and More
Finally, I had a short list of other requirements.
- A test harness that could be used to test an implementation of the API against a known set of tests and against the standard library reference implementation. This would ensure that all implementations behave as expected.
- Little to no dependencies.
- Transparent tooling that allows for developers to understand what is to be packed, what declarations were found, etc…
Introducing Pkger
Today I would like to introduce Pkger, a tool for embedding static files into Go binaries that is idiomatic, lossless, and less complex.
The API and Interfaces
Pkger comprises of three core interfaces. The first interface, pkging.Pkger
, comprises of functions from the os
and path/filepath
packages.
type Pkger interface {
Parse(p string) (here.Path, error)
Current() (here.Info, error)
Info(p string) (here.Info, error)
Create(name string) (File, error)
MkdirAll(p string, perm os.FileMode) error
Open(name string) (File, error)
Stat(name string) (os.FileInfo, error)
Walk(p string, wf filepath.WalkFunc) error
Remove(name string) error
RemoveAll(path string) error
}
The next interface is pkging.File
. This interface is designed around the os.File
type.
type File interface {
Close() error
Info() here.Info
Name() string
Open(name string) (http.File, error)
Path() here.Path
Read(p []byte) (int, error)
Readdir(count int) ([]os.FileInfo, error)
Seek(offset int64, whence int) (int64, error)
Stat() (os.FileInfo, error)
Write(b []byte) (int, error)
}
These two interfaces when combined with the os.FileInfo
interface provide enough functionality to enable an almost identical experience when using pkger or the standard library.
func run() error {
info, err := pkger.Stat("/go.mod")
if err != nil {
return err
}
fmt.Println(info)
if err := pkger.MkdirAll("/foo/bar/baz", 0755); err != nil {
return err
} f, err := pkger.Create("/foo/bar/baz/biz.txt")
if err != nil {
return err
}
f.Write([]byte("BIZ!!"))
if err := f.Close(); err != nil {
return err
} f, err = pkger.Open("/foo/bar/baz/biz.txt")
if err != nil {
return err
}
io.Copy(os.Stdout, f)
return f.Close()
}
Module Aware Pathing
Pathing in Pkger uses GOMOD
and other parts of the Go standard tool chain to determine the root of the Module, it’s import path, other important meta data that allows Pkger to accurately find any file inside of a Module.
Let’s use the following folder structure which we’ll place in $HOME/myapp.
.
├── go.mod
├── go.sum
├── main.go
└── public
├── images
│ ├── img1.png
│ └── img2.png
└── index.html
And the following go.mod
file.
module myappgo 1.13require github.com/markbates/pkger v0.10.0
To stat the index.html
file using Pkger we can write a main.go
such as the following.
package mainimport (
"fmt"
"log"
"github.com/markbates/pkger"
)func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}func run() error {
info, err := pkger.Stat("/public/index.html")
if err != nil {
return err
} fmt.Println("Name: ", info.Name())
fmt.Println("Size: ", info.Size())
fmt.Println("Mode: ", info.Mode())
fmt.Println("ModTime: ", info.ModTime()) return nil
}
The GOMOD
for this application is $HOME/myapp
Pkger joins that with /public/index.html
to find the file on disk.
Name: index.html
Size: 257
Mode: -rw-r--r--
ModTime: 2019-11-01 16:12:24.783887046 -0400 EDT
Pkger uses static analysis and walks the AST of your application find certain declarations such as pkger.Open
, pkger.Walk
, and others. Because of this it can’t determine dynamic runtime values. I would love to see community help to improve, and expand, the parser.
Transparent Tooling
Tooling, especially tooling that provides a transparent view into the tool itself, is crucial to a tool like this.
Usage:pkger [flags] [args...]
-h prints help information ("false")pkger info [flags] [args...]pkger list [flags] [args...]
-json prints in JSON format ("false")pkger parse [flags] [args...]
-h prints help information ("false")pkger path [flags] [args...]
-h prints help information ("false")pkger serve [flags] [args...]pkger stat [flags] [args...]
The top level function will generate a pkged.go
containing only the files your application uses via Pkger static analysis tools. To find out what files those are the pkger list
command prints a list of the files that pkger is aware of.
app
> app:/public/index.html
To see what Pkger’s parser sees, the pkger parse
command dumps a JSON representation of the results of parsing the current module.
{
".": [
{
"file": {
"Abs": "$HOME/myapp/main.go",
"Path": {
"pkg": "myapp",
"name": "/public/index.html"
},
"Here": {
"Dir": "$HOME/myapp",
"ImportPath": "myapp",
"Module": {
"path": "myapp",
"main": true,
"dir": "$HOME/myapp",
"go_mod": "",
"go_version": ""
},
"Name": "main"
}
},
"pos": {
"Filename": "$HOME/myapp/main.go",
"Offset": 183,
"Line": 17,
"Column": 26
},
"type": "pkger.Stat",
"value": "/public/index.html"
}
]
}
Wrapping Up and the Future of Packr
I’m asking today for the community’s help in testing, opening PR’s, and feedback on this new tool. The more people who contribute to a project such as this help make it stronger and more stable for everyone else. Please watch the video, play with the examples, and join in helping make this the solution for static file embedding we need.
As for Packr and it’s future, it is unsure. I believe Pkger is the path forward, and barring any large surprises we find in the community, it will most certainly be the path forward for Buffalo. As the project progresses you can expect more posts and videos exploring Pkger.
It is my hope that those who still want to use Packr will help with its maintainence. If you are interested in becoming a maintainer of Packr please reach out.
Are you a company looking for Buffalo or Go training? The Gopher Guides want to help. We offer a variety of options, including In Person Training, Instructor Led Online Workshops, Consulting, and Support Contracts, and our self paced Online Video Learning.
Get your first 3 months of www.gopherguides.tv for 33% off! This offer is only good for a limited time, and a limited number, so act fast!