Résumé Builder Part III
10 May 2016
In Part I, I introduced the tool and the data. In Part II, I discussed how the tool interacts with the data. Today, I’ll take some time to go through the command line interface for the tool.
Any time I sit down to write a CLI, I try to think of a way to design it for
standard streams. The simplicity and utility of a tool that listens to stdin
and sends to stdout
and stderr
continues to be my personal favorite UI. If
a tool I build is able to act on lines to produce lines, I have a whole world of
additional tooling that I can employ to make the most of my tool.
I could not think of a simple way to make use of standard streams for the résumé tool. The major problem is that the particular formats for both the data and the templates that consume the data cannot be consumed line by line. If the inputs only work as whole files, it makes more sense to operate on files than on streams. I will make a tool soon that harnesses the power of the streams, but the résumé builder is not that tool.
We can start by outlining the basic operations for the tool. It really is as simple as the following.
func main() {
// figure out what the user wants and complain if it is not right
// grab the data
// grab template files
// execute templates
// save résumé files
}
Using the flag package, we define and document the information we need from a user in order to build out the résumé files.
type stringSlice []string
func (ss *stringSlice) String() string {
s := ""
for _, si := range *ss {
s += si
}
return s
}
func (ss *stringSlice) Set(value string) error {
for _, s := range strings.Split(value, ",") {
*ss = append(*ss, s)
}
return nil
}
func main() {
var (
inputFile string
outputDir string
templateFiles stringSlice
forceSave bool
)
flag.StringVar(&inputFile, "i", "", "[required] json input file containing résumé data")
flag.StringVar(&outputDir, "o", ".", "output directory to save résumé files to")
flag.Var(&templateFiles, "t", "[required] individual template files to generate résumés. For each template file 'resume.md.tmpl', an output file 'resume.md' will be created")
flag.BoolVar(&forceSave, "f", false, "force save over existing files")
flag.Parse()
if len(inputFile) == 0 || len(templateFiles) == 0 {
flag.PrintDefaults()
os.Exit(1)
}
…
}
Some of these are fairly obvious. The inputFile
, outputDir
, and forceSave
are just simple flags. The more interesting flag is templateFiles
. With the
other flags, we only needed to concern ourselves with a single value, but we
want to parse multiple template files for the résumé. One option would be to
impose a convention that all the template files would be in a given directory,
and all templates would be parsed every time. I don’t like that. I think it puts
too much burden on the consumer of the API without adding any benefit. Instead,
I think a better, more explicit way is to have a flag that accepts multiple
values. The flag package provides a clean and
flexible way for us to do this. Implementing the flag.Value
interface allows
custom types and customized parsing for flags. In this case, we made the
stringSlice
to allow the user to pass template file names as a comma-separated
list and as multiple -t filename.xyz.tmpl
flags in the command. I think this
is a cleaner, more flexible, easier to think through interface.
We also take the time here to check that the required flags are passed, and to check for and create the output directory for the generated résumé files. That quick and easy bit is shown below.
func main() {
…
if err := createOutputDirIfNotExist(outputDir); err != nil {
log.Fatal(err)
}
…
}
func createOutputDirIfNotExist(outputDir string) error {
info, err := os.Stat(outputDir)
if err == nil {
if info.IsDir() {
return nil
} else {
return errors.New("Cannot replace file with directory")
}
} else if !os.IsNotExist(err) {
return err
}
return os.MkdirAll(outputDir, os.ModePerm|os.ModeDir)
}
According to our original outline, we need to grab the data next. Because of the work that we did previously, this step becomes fairly trivial boilerplate.
func main() {
…
// grab the data
resume, err := getResumeFromFile(inputFile)
if err != nil {
log.Fatal(err)
}
log.Printf("Loaded Résumé data from %q", inputFile)
…
}
func getResumeFromFile(in string) (resume *Resume, err error) {
var resumeJson []byte
resume = new(Resume)
resumeJson, err = ioutil.ReadFile(in)
if err != nil {
return nil, err
}
err = json.Unmarshal(resumeJson, resume)
if err != nil {
return nil, err
}
return resume, nil
}
Next, we grab the template files. The resumeVersion
type, which we may explore
in another post, represents not only the template, but the specific output and
destination of that template. For the purposes of the command line interface, we
only need to know that the resumeVersion
parses the templates
(newResumeVersion()
), executes the templates (rv.Execute()
), and saves the
resulting files (rv.Save()
).
func main() {
…
// grab template files
var rvs []*resumeVersion
for _, t := range templateFiles {
if rv, err := newResumeVersion(t, outputDir); err != nil {
log.Printf("Failed to load %q: %v", t, err)
} else {
rvs = append(rvs, rv)
log.Printf("Loaded template: %q", t)
}
}
…
}
Did you notice anything about the error handling above? It’s only a very small detail, but it speaks to what I find to be one of Go’s greatest strengths. Go forces you to care about the nature of every error. In this case, the error really is some kind of failure to properly load the template. It doesn’t matter why the template failed to load, so we don’t need to look beyond whether there was an error or not. Each of the templates are essentially independent of one another, so other then telling the user that there was a problem with one of the template, I see no reason to stop execution. This could probably be improved on with a prompt, asking the user if they would like to abort the whole operation, but it would be overkill for this small an application.
The execute and save steps follow this same pattern.
func main() {
…
// execute templates
var executed []*resumeVersion
for _, rv := range rvs {
if err := rv.Execute(*resume); err != nil {
log.Printf("Failed to execute %q: %v", rv.Name(), err)
} else {
executed = append(executed, rv)
log.Printf("Executed template: %q", rv.Name())
}
}
// save résumé files
for _, rv := range executed {
if err := rv.Save(forceSave); err != nil {
log.Printf("Failed to save %q: %v", rv.Name(), err)
} else {
log.Printf("Saved: %q", rv.Name())
}
}
…
}
In a future post, I will be discussing the design of the resumeVersion
type
and going more in depth on why we even need it. The little bit I want to
address now is concurrency. Go is known for making concurrency first class in
the design of the language. Therefore, it becomes habit to think of program flow
in terms of sharing by communicating. Here, I chose to encapsulate all the
important information into individual units that can be passed to the next steps
without need to coordinate with any outside information or operations. It might
be tempting to handle loading, executing, and saving as a single step for each
template. It would certainly be less code. We would not need a resumeVersion
type at all. And with a tool this size, and operations this inexpensive, that
would be a justifiable choice. However, by separating these operations, we open
up some potential future performance improvements and make the code easier to
reason about in the process. I have other projects that are higher priority at
the moment, so I will leave it as “an exercise for the reader” to make this
simple improvement for now. Go ahead and fork the project,
use the tool, and submit your improvements as pull requests.