|
| 1 | ++++ |
| 2 | +title = "The Gift of Good Tooling" |
| 3 | +date = "2025-01-01T11:34:37-06:00" |
| 4 | +author = "verygoodsoftwarenotvirus" |
| 5 | +cover = "" |
| 6 | +tags = [] |
| 7 | +keywords = [] |
| 8 | +description = "" |
| 9 | +showFullContent = false |
| 10 | +readingTime = true |
| 11 | ++++ |
| 12 | + |
| 13 | +Recently I joined a new team at work, and one my favorite parts of joining a new team is seeing what tools are and aren’t in use, and most importantly, why or why not. I don’t think I’ve ever joined a team that didn’t introduce me to a new library or utility, most good, some bad. |
| 14 | + |
| 15 | +In today’s case, the tool I want to talk about is caarlos0’s [env](https://github.com/caarlos0/env). It’s a library that effectively allows you to map environment variables to the values of individual structs. So if I have a config struct like: |
| 16 | + |
| 17 | +```go |
| 18 | +type Config struct { |
| 19 | + Debug bool `env:"DEBUG"` |
| 20 | +} |
| 21 | +``` |
| 22 | + |
| 23 | +At runtime, if `Debug` is set to `false`, but the environment variable `DEBUG` is set to `true`, `config.Debug` will be `true` after I use the `env` library to load this struct. |
| 24 | + |
| 25 | +I understood how it all worked, but didn’t understand how valuable it was until we had to diagnose a bug in production at work. We needed to disable something in production and rather than having to create a PR and go through a bunch of CI/CD rigamarole, we edited the pod’s YAML in k9s and confirmed the issue. I felt like a god and I wasn’t responsible for any of it. |
| 26 | + |
| 27 | +My curse and blessing is that I’m the kind of person who cannot experience a great tooling experience and not immediately find a nail for that hammer. I maintain a constantly-morphing side project almost *because* it frequently gives me that nail. |
| 28 | + |
| 29 | +One hangup I had about using it in my side project is documenting the consequent flags. `env` allows you to set a prefix for nested values, and the config I use in my side project is very nested. To adapt our earlier example: |
| 30 | + |
| 31 | +```go |
| 32 | +type DatabaseConfig struct { |
| 33 | + Debug bool `env:"DEBUG"` |
| 34 | +} |
| 35 | + |
| 36 | +type ServiceConfig struct { |
| 37 | + Debug bool `env:"DEBUG"` |
| 38 | + Database DatabaseConfig `envPrefix:"DATABASE_"` |
| 39 | +} |
| 40 | +``` |
| 41 | + |
| 42 | +This would allow you to set `svcCfg.Database.Debug` by setting the environment variable `DATABASE_DEBUG`. That’s all well and good, easy enough to suss out, but it’s also precisely the sort of mental toil I’m willing to spend more time than I could ever potentially save trying to avoid. |
| 43 | + |
| 44 | +So I (with some help from ChatGPT) wrote some code to parse the AST and produce a library of string constants that document their responsibility: |
| 45 | + |
| 46 | +```go |
| 47 | +package main |
| 48 | + |
| 49 | +import ( |
| 50 | + "fmt" |
| 51 | + "go/ast" |
| 52 | + "go/parser" |
| 53 | + "go/token" |
| 54 | + "log" |
| 55 | + "os" |
| 56 | + "path/filepath" |
| 57 | + "slices" |
| 58 | + "strings" |
| 59 | + |
| 60 | + "github.com/<side_project>/internal/config" |
| 61 | + |
| 62 | + "github.com/codemodus/kace" |
| 63 | +) |
| 64 | + |
| 65 | +func main() { |
| 66 | + dir, err := os.Getwd() |
| 67 | + if err != nil { |
| 68 | + log.Fatal(err) |
| 69 | + } |
| 70 | + |
| 71 | + structs := parseGoFiles(dir) |
| 72 | + |
| 73 | + outputLines := []string{} |
| 74 | + if mainAST, found := structs["config.APIServiceConfig"]; found { |
| 75 | + for envVar, fieldPath := range extractEnvVars(mainAST, structs, "main", "", "") { |
| 76 | + outputLines = append(outputLines, fmt.Sprintf(` // %sEnvVarKey is the environment variable name to set in order to override `+"`"+`config%s`+"`"+`. |
| 77 | + %sEnvVarKey = "%s%s" |
| 78 | +
|
| 79 | +`, kace.Pascal(envVar), fieldPath, kace.Pascal(envVar), config.EnvVarPrefix, envVar)) |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + slices.Sort(outputLines) |
| 84 | + |
| 85 | + out := fmt.Sprintf(`package envvars |
| 86 | +
|
| 87 | +/* |
| 88 | +This file contains a reference of all valid service environment variables. |
| 89 | +*/ |
| 90 | +
|
| 91 | +const ( |
| 92 | +%s |
| 93 | +) |
| 94 | +`, strings.Join(outputLines, "")) |
| 95 | + |
| 96 | + if err = os.WriteFile(filepath.Join(dir, "internal", "config", "envvars", "env_vars.go"), []byte(out), 0o0644); err != nil { |
| 97 | + log.Fatal(err) |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +// parseGoFiles parses all Go files in the given directory and returns a map of struct names to their AST nodes. |
| 102 | +func parseGoFiles(dir string) map[string]*ast.TypeSpec { |
| 103 | + structs := make(map[string]*ast.TypeSpec) |
| 104 | + |
| 105 | + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { |
| 106 | + if err != nil { |
| 107 | + return err |
| 108 | + } |
| 109 | + |
| 110 | + if info.IsDir() || !strings.HasSuffix(info.Name(), ".go") { |
| 111 | + return nil |
| 112 | + } |
| 113 | + |
| 114 | + if strings.Contains(path, "vendor") { |
| 115 | + return filepath.SkipDir |
| 116 | + } |
| 117 | + |
| 118 | + node, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.AllErrors) |
| 119 | + if err != nil { |
| 120 | + fmt.Printf("Error parsing file %s: %v\n", path, err) |
| 121 | + return nil |
| 122 | + } |
| 123 | + |
| 124 | + for _, decl := range node.Decls { |
| 125 | + genDecl, isGenDecl := decl.(*ast.GenDecl) |
| 126 | + if !isGenDecl { |
| 127 | + continue |
| 128 | + } |
| 129 | + |
| 130 | + for _, spec := range genDecl.Specs { |
| 131 | + typeSpec, isTypeSpec := spec.(*ast.TypeSpec) |
| 132 | + if !isTypeSpec { |
| 133 | + continue |
| 134 | + } |
| 135 | + |
| 136 | + if _, ok = typeSpec.Type.(*ast.StructType); ok { |
| 137 | + key := fmt.Sprintf("%s.%s", node.Name.Name, typeSpec.Name.Name) |
| 138 | + structs[key] = typeSpec |
| 139 | + } |
| 140 | + } |
| 141 | + } |
| 142 | + return nil |
| 143 | + }) |
| 144 | + |
| 145 | + if err != nil { |
| 146 | + fmt.Printf("Error walking directory: %v\n", err) |
| 147 | + } |
| 148 | + |
| 149 | + return structs |
| 150 | +} |
| 151 | + |
| 152 | +// getTagValue extracts the value of a specific tag from a struct field tag. |
| 153 | +func getTagValue(tag, key string) string { |
| 154 | + tags := strings.Split(tag, " ") |
| 155 | + for _, t := range tags { |
| 156 | + parts := strings.SplitN(t, ":", 2) |
| 157 | + if len(parts) == 2 && parts[0] == key { |
| 158 | + return strings.Trim(parts[1], "\"") |
| 159 | + } |
| 160 | + } |
| 161 | + return "" |
| 162 | +} |
| 163 | + |
| 164 | +// handleIdent handles extracting info from an *ast.Ident node. |
| 165 | +func handleIdent(structs map[string]*ast.TypeSpec, fieldType *ast.Ident, envVars map[string]string, currentPackage, prefixValue, fieldNamePrefix, fieldName string) { |
| 166 | + for key, nestedStruct := range structs { |
| 167 | + keyParts := strings.Split(key, ".") |
| 168 | + if len(keyParts) == 2 && keyParts[1] == fieldType.Name { |
| 169 | + if keyParts[0] == currentPackage || currentPackage == "main" { |
| 170 | + for k, v := range extractEnvVars(nestedStruct, structs, keyParts[0], prefixValue, fmt.Sprintf("%s.%s", fieldNamePrefix, fieldName)) { |
| 171 | + envVars[k] = v |
| 172 | + } |
| 173 | + } |
| 174 | + } |
| 175 | + } |
| 176 | +} |
| 177 | + |
| 178 | +// handleSelectorExpr handles extracting info from an *ast.SelectorExpr node. |
| 179 | +func handleSelectorExpr(structs map[string]*ast.TypeSpec, fieldType *ast.SelectorExpr, envVars map[string]string, prefixValue, fieldNamePrefix, fieldName string) { |
| 180 | + if pkgIdent, isIdentifier := fieldType.X.(*ast.Ident); isIdentifier { |
| 181 | + pkgName := pkgIdent.Name |
| 182 | + |
| 183 | + fullName := fmt.Sprintf("%s.%s", pkgName, fieldType.Sel.Name) |
| 184 | + if nestedStruct, found := structs[fullName]; found { |
| 185 | + for k, v := range extractEnvVars(nestedStruct, structs, pkgName, prefixValue, fmt.Sprintf("%s.%s", fieldNamePrefix, fieldName)) { |
| 186 | + envVars[k] = v |
| 187 | + } |
| 188 | + } |
| 189 | + } |
| 190 | +} |
| 191 | + |
| 192 | +// extractEnvVars traverses a struct definition and collects environment variables, resolving nested structs. |
| 193 | +func extractEnvVars(typeSpec *ast.TypeSpec, structs map[string]*ast.TypeSpec, currentPackage, envVarPrefix, fieldNamePrefix string) map[string]string { |
| 194 | + envVars := map[string]string{} |
| 195 | + |
| 196 | + structType, ok := typeSpec.Type.(*ast.StructType) |
| 197 | + if !ok { |
| 198 | + return envVars |
| 199 | + } |
| 200 | + |
| 201 | + for _, field := range structType.Fields.List { |
| 202 | + if field.Tag == nil { |
| 203 | + continue |
| 204 | + } |
| 205 | + |
| 206 | + tag := strings.Trim(field.Tag.Value, "`") |
| 207 | + if tag == `json:"-"` || tag == "" { |
| 208 | + continue |
| 209 | + } |
| 210 | + |
| 211 | + fn := field.Names[0].Name |
| 212 | + |
| 213 | + if envValue := getTagValue(tag, "env"); envValue != "" { |
| 214 | + if envVarPrefix != "" { |
| 215 | + envValue = envVarPrefix + envValue |
| 216 | + } |
| 217 | + |
| 218 | + if fieldNamePrefix == "" { |
| 219 | + envVars[envValue] = fn |
| 220 | + } else { |
| 221 | + envVars[envValue] = fmt.Sprintf("%s.%s", fieldNamePrefix, fn) |
| 222 | + } |
| 223 | + } |
| 224 | + |
| 225 | + if prefixValue := getTagValue(tag, "envPrefix"); prefixValue != "" { |
| 226 | + if envVarPrefix != "" { |
| 227 | + prefixValue = envVarPrefix + prefixValue |
| 228 | + } |
| 229 | + |
| 230 | + switch fieldType := field.Type.(type) { |
| 231 | + case *ast.Ident: |
| 232 | + handleIdent(structs, fieldType, envVars, currentPackage, prefixValue, fieldNamePrefix, fn) |
| 233 | + case *ast.SelectorExpr: |
| 234 | + handleSelectorExpr(structs, fieldType, envVars, prefixValue, fieldNamePrefix, fn) |
| 235 | + case *ast.StarExpr: |
| 236 | + switch ft := fieldType.X.(type) { |
| 237 | + case *ast.Ident: |
| 238 | + handleIdent(structs, ft, envVars, currentPackage, prefixValue, fieldNamePrefix, fn) |
| 239 | + case *ast.SelectorExpr: |
| 240 | + handleSelectorExpr(structs, ft, envVars, prefixValue, fieldNamePrefix, fn) |
| 241 | + } |
| 242 | + } |
| 243 | + } |
| 244 | + } |
| 245 | + |
| 246 | + return envVars |
| 247 | +} |
| 248 | + |
| 249 | +``` |
| 250 | + |
| 251 | +The only code here specific to my service is the import of a string constant `config.EnvVarPrefix`, which we’ll say for the sake of example is `SIDE_PROJECT_`, and the name of the config struct the code looks for (`config.APIServiceConfig`). Everything else *should* produce a file that looks something like this: |
| 252 | + |
| 253 | +```go |
| 254 | +package envvars |
| 255 | + |
| 256 | +/* |
| 257 | +This file contains a reference of all valid service environment variables. |
| 258 | +*/ |
| 259 | + |
| 260 | +const ( |
| 261 | + // AnalyticsCircuitBreakerErrorRateEnvVarKey is the environment variable name to set in order to override `config.Analytics.CircuitBreakerConfig.ErrorRate`. |
| 262 | + AnalyticsCircuitBreakerErrorRateEnvVarKey = "SIDE_PROJECT_ANALYTICS_CIRCUIT_BREAKER_ERROR_RATE" |
| 263 | + |
| 264 | + // AnalyticsCircuitBreakerMinimumSampleThresholdEnvVarKey is the environment variable name to set in order to override `config.Analytics.CircuitBreakerConfig.MinimumSampleThreshold`. |
| 265 | + AnalyticsCircuitBreakerMinimumSampleThresholdEnvVarKey = "SIDE_PROJECT_ANALYTICS_CIRCUIT_BREAKER_MINIMUM_SAMPLE_THRESHOLD" |
| 266 | + |
| 267 | + // AnalyticsPosthogCircuitBreakingErrorRateEnvVarKey is the environment variable name to set in order to override `config.Analytics.Posthog.CircuitBreakerConfig.ErrorRate`. |
| 268 | + AnalyticsPosthogCircuitBreakingErrorRateEnvVarKey = "SIDE_PROJECT_ANALYTICS_POSTHOG_CIRCUIT_BREAKING_ERROR_RATE" |
| 269 | + |
| 270 | + |
| 271 | + /* |
| 272 | + ...and so on and so forth |
| 273 | + */ |
| 274 | +) |
| 275 | +``` |
| 276 | + |
| 277 | +Which documents not just every valid environment variable, but also the field name that it manipulates. As with any generated code in this project, this file [is checked for consistency in CI](https://blog.verygoodsoftwarenotvirus.ru/posts/generated-files/), so I can count on this being accurate and up to date, and I’m free to rename and move fields around at my leisure. |
| 278 | + |
| 279 | +This took me maybe a day or so to do, and if I never encounter a need to make use of this, it will technically have been wasted time. I know myself well enough, however, to know that if I experienced failing to change a value with an outdated environment variable that I had populated by hand, I’d be cursing myself for not spending the day. If I never endeavored to document it, and I had to trawl through this expansive config keeping prefixes in mind in order to deduce what the environment variable was, I’d be cursing myself for not spending the day. |
| 280 | + |
| 281 | +Instead, I took the day to give myself the gift of good tooling, and I can spend subsequent days worrying about the problems I’m actually trying to solve, and not letting toil get in the way of progress. |
0 commit comments