jetti
제티(jetti)는 고 언어 프로젝트에 어느정도 정규화된 프로젝트 구성을 적용하기 위한 도구입니다.
지향점
[WIP]
install
다음 명령어를 실행하면 $HOME/go/bin에 jetti 바이너리가 설치됩니다.
latest로 버전을 특정하면 항상 최신 버전을 설치합니다.
go install github.com/snowmerak/jetti/v2/cmd/jetti@latest
new
new는 새로운 프로젝트나 커맨드 패키지를 생성합니다.
new module
jetti new <module-name>을 실행하면 현재 폴더에서 go mod <module-name>을 실행하면서 다음과 같은 기본 폴더들을 만들어줍니다.
➜ tree .
.
├── README.md
├── cmd
│ └── doc.go
├── go.mod
├── internal
│ └── doc.go
└── lib
└── doc.go
4 directories, 5 files
new command
jetti new --cmd <cmd-name>을 실행하면 현재 폴더 내의 cmd 폴더에 <cmd-name> 폴더를 만들고, main.go 파일을 만들어줍니다.
다음 예시는 jetti new --cmd prac를 실행한 결과입니다.
➜ jetti new --cmd prac
➜ tree .
.
├── README.md
├── cmd
│ ├── doc.go
│ └── prac
│ └── main.go
├── go.mod
├── internal
│ └── doc.go
└── lib
└── doc.go
5 directories, 6 files
new proto
jetti new --proto <path/name>을 실행하면 현재 폴더 내의 <path> 폴더를 만들고, <name>.proto 파일을 만듭니다.
다음 예시는 jetti new --proto model/proto/person를 실행한 결과입니다.
syntax = "proto3";
package person;
option go_package = "model/proto/person";
run
run은 cmd 내의 커맨드 패키지를 실행하는 역할을 합니다.
jetti run <cmd-name>을 실행하면 cmd/<cmd-name> 폴더 내의 고 파일들을 실행합니다.
추가로 jetti run <cmd-name> <args>...을 실행하여 커맨드 패키지에 인자를 전달할 수 있습니다.
사실상 go run과 동일합니다.
request scope data
request scope data는 context.Context의 WithValue를 편리하게 이용할 수 있게 해주는 기능입니다.
request scope data의 핵심은 동등, 혹은 하위 문맥에서 동일한 데이터를 공유하는 것입니다.
예시
./lib/config 폴더를 만들고 config.go 파일을 만들어 다음과 같이 작성합니다.
package config
// jetti:request redis postgres
type Config struct {
}
jetti:request 주석을 통해 redis와 postgres 빈을 등록했습니다.
이제 터미널에 jetti generate를 입력하면 ./lib/redis.context.go와 ./lib/postgres.context.go 파일이 생성됩니다.
// postgres.context.go
package config
import "context"
type PostgresContextKey string
func PushPostgres(ctx context.Context, v *Config) context.Context {
return context.WithValue(ctx, PostgresContextKey("Postgres"), v)
}
func GetPostgres(ctx context.Context) (*Config, bool) {
v, ok := ctx.Value(PostgresContextKey("Postgres")).(*Config)
return v, ok
}
package config
import "context"
type RedisContextKey string
func PushRedis(ctx context.Context, v *Config) context.Context {
return context.WithValue(ctx, RedisContextKey("Redis"), v)
}
func GetRedis(ctx context.Context) (*Config, bool) {
v, ok := ctx.Value(RedisContextKey("Redis")).(*Config)
return v, ok
}
이제 단일 컨텍스트를 생성한 후, Push 메서드를 통해 데이터를 컨텍스트에 추가하고, Get 메서드를 통해 데이터를 가져올 수 있습니다.
optional parameter
optional parameter는 jetti:parameter 주석을 통해 생성할 수 있습니다.
옵셔널 패러미터는 기존의 프리미티브 타입, 혹은 구조체에 기본값과 값 변경을 위한 함수를 받아 기본값을 변형하여 새로운 패러미터를 반환합니다.
예시
./lib/person 폴더를 만들고 person.go 파일을 만들어 다음과 같이 작성합니다.
package person
// jetti:parameter
type Person struct {
Name string
Age int
}
jetti generate를 실행하면 ./lib/person.parameter.go 파일이 생성됩니다.
package person
type PersonOptional func(*Person) *Person
func ApplyPerson(defaultValue Person, fn ...PersonOptional) *Person {
param := &defaultValue
for _, f := range fn {
param = f(param)
}
return param
}
ApplyPerson 함수에 기본값과 변형 함수를 전달하여 새로운 Person 구조체를 생성합니다.
json/yaml to go
go-jsonstruct 라이브러리를 이용해서 json/yaml 파일을 go 구조체로 변환할 수 있습니다.
파싱에는 각각 goccy/go-json과 goccy/go-yaml 라이브러리를 사용합니다.
예시
./config/json_prac.json과 ./config/yaml_prac.yaml 파일을 만들고 다음과 같이 작성합니다.
{
"name": "snowmerak",
"version": "1.3.2",
"author": "snowmerak",
"dependencies": {
"go": "github.com/golang/go",
"rust": "github.com/rust-lang/rust"
}
}
name: snowmerak
version: 1.3.2
author: snowmerak
dependencies:
go: github.com/golang/go
rust: github.com/rust-lang/rust
그리고 jetti generate를 실행하면 다음 파일 들이 생성됩니다.
// json_prac.json.go
package config
import "github.com/goccy/go-json"
import "io"
import "os"
func JsonPracFromJSON(data []byte) (*JsonPrac, error) {
v := new(JsonPrac)
if err := json.Unmarshal(data, v); err != nil {
return nil, err
}
return v, nil
}
func JsonPracFromFile(path string) (*JsonPrac, error) {
f, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return JsonPracFromJSON(f)
}
func (jsonprac *JsonPrac) Marshal2JSON() ([]byte, error) {
return json.Marshal(jsonprac)
}
func (jsonprac *JsonPrac) Encode2JSON(w io.Writer) error {
return json.NewEncoder(w).Encode(jsonprac)
}
type JsonPrac struct {
Author string `json:"author"`
Dependencies struct {
Go string `json:"go"`
Rust string `json:"rust"`
} `json:"dependencies"`
Name string `json:"name"`
Version string `json:"version"`
}
// yaml_prac.yaml.go
package config
import "github.com/goccy/go-yaml"
import "io"
import "os"
func YamlPracFromYAML(data []byte) (*YamlPrac, error) {
v := new(YamlPrac)
if err := yaml.Unmarshal(data, v); err != nil {
return nil, err
}
return v, nil
}
func YamlPracFromFile(path string) (*YamlPrac, error) {
f, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return YamlPracFromYAML(f)
}
func (yamlprac *YamlPrac) Marshal2YAML() ([]byte, error) {
return yaml.Marshal(yamlprac)
}
func (yamlprac *YamlPrac) Encode2YAML(w io.Writer) error {
return yaml.NewEncoder(w).Encode(yamlprac)
}
type YamlPrac struct {
Author string `yaml:"author"`
Dependencies struct {
Go string `yaml:"go"`
Rust string `yaml:"rust"`
} `yaml:"dependencies"`
Name string `yaml:"name"`
Version string `yaml:"version"`
}
protobuf/flatbuffers generating
제티는 프로젝트 내부의 프로토버퍼와 플랫버퍼 파일을 찾으면 자동으로 고 코드로 컴파일 하는 커맨드를 실행합니다.
이를 위해 protoc와 flatc가 필요합니다.
protoc/grpc 설치
여기를 참고해 protoc 및 고 코드 생성을 위한 플러그인을 설치합니다.
flatc 설치
여기를 참고해 flatc를 설치합니다.
굳이 빌드 하지 않더라도 사용하는 환경의 패키지 매니저를 통해 설치할 수 있습니다.
사용법
./proto 디렉토리를 만들고 ./proto/test/test.proto 파일을 만듭니다.
syntax = "proto3";
package test;
option go_package = "./test";
message Test {
string name = 1;
int32 age = 2;
}
그리고 jetti generate를 실행하면 ./gen/grpc/proto/test/test.pb.go에 파일을 생성합니다.
object pool
제티는 sync.Pool과 chan T을 사용한 두가지 풀을 만들 수 있습니다.
sync pool
jetti:pool을 주석에 작성함으로 풀을 생성할 수 있습니다.
두 가지 풀 중, sync.Pool은 jetti:pool sync:<alias>로 생성할 수 있습니다.
<alias>는 풀의 이름을 지정합니다.
// jetti:pool sync:people
type Person struct {
Name string
Age int
}
위와 같이 주석을 작성하면 sync.Pool을 이용한 people 풀이 생성됩니다.
package person
import "sync"
import "errors"
import "runtime"
var errPeopleCannotGet error = errors.New("cannot get people")
type PeoplePool struct {
pool *sync.Pool
}
func (p *PeoplePool) Get() (*Person, error) {
v := p.pool.Get()
if v == nil {
return nil, errPeopleCannotGet
}
return v.(*Person), nil
}
func (p *PeoplePool) GetWithFinalizer() (*Person, error) {
v := p.pool.Get()
if v == nil {
return nil, errPeopleCannotGet
}
runtime.SetFinalizer(v, func(v interface{}) {
p.pool.Put(v)
})
return v.(*Person), nil
}
func (p *PeoplePool) Put(v *Person) {
p.pool.Put(v)
}
func NewPeoplePool() PeoplePool {
return PeoplePool{
pool: &sync.Pool{
New: func() interface{} {
return new(Person)
},
},
}
}
func IsPeopleCannotGetErr(err error) bool {
return errors.Is(err, errPeopleCannotGet)
}
chan pool
채널을 사용한 풀은 jetti:pool chan:<alias>로 생성할 수 있습니다.
이 풀의 경우엔, 최대 풀링 가능한 오브젝트 수를 제한할 때 유용하게 사용할 수 있습니다.
// jetti:pool sync:people chan:candidate
type Person struct {
Name string
Age int
}
방금 예제에서 sync:people 뒤에 chan:candidate를 추가해서 생성하면, 추가적으로 다음 파일도 생성됩니다.
package person
import (
"runtime"
"time"
)
type CandidatePool struct {
pool chan *Person
timeout time.Duration
}
func (c *CandidatePool) Get() *Person {
after := time.After(c.timeout)
select {
case v := <-c.pool:
return v
case <-after:
return new(Person)
}
}
func (c *CandidatePool) GetWithFinalizer() *Person {
after := time.After(c.timeout)
resp := (*Person)(nil)
select {
case v := <-c.pool:
resp = v
case <-after:
resp = new(Person)
}
runtime.SetFinalizer(resp, func(v interface{}) {
c.pool <- v.(*Person)
})
return resp
}
func (c *CandidatePool) Put(v *Person) {
select {
case c.pool <- v:
default:
}
}
func NewCandidatePool(size int, timeout time.Duration) CandidatePool {
pool := make(chan *Person, size)
return CandidatePool{
pool: pool,
timeout: timeout,
}
}
sync pool과 다른 점으로 전체 채널 길이와 채널에서 값을 가져올 시간의 제한을 지정합니다.
optional
jetti:optional을 사용하여 해당 타입의 옵셔널 타입을 만들 수 있습니다.
package person
// jetti:optional
type Person struct {
Name string
Age int
}
// jetti:optional
type People [100]Person
예시에서는 person 패키지와 이름이 같은 Person과 Person 타입의 배열인 People을 옵셔널 타입으로 만들었습니다.
package person
type OptionalPeople struct {
value *People
valid bool
}
func (o *OptionalPeople) Unwrap() *People {
if !o.valid {
panic("unwrap a none value")
}
return o.value
}
func (o *OptionalPeople) IsSome() bool {
return o.valid
}
func (o *OptionalPeople) IsNone() bool {
return !o.valid
}
func (o *OptionalPeople) UnwrapOr(defaultValue *People) *People {
if !o.valid {
return defaultValue
}
return o.value
}
func SomePeople(value *People) OptionalPeople {
return OptionalPeople{
value: value,
valid: true,
}
}
func NonePeople() OptionalPeople {
return OptionalPeople{
valid: false,
}
}
먼저 People은 위와같이 OptionalPeople 타입으로 감싸집니다.
그리고 SomePeople과 NonePeople 함수를 통해 생성할 수 있으며, Unwrap과 UnwrapOr 함수를 통해 값을 꺼낼 수 있습니다.
주의할 점은 Unwrap은 패닉을 발생할 수 있기에 IsSome을 통해 값이 있는지 확인하고 사용해야 합니다.
package person
type OptionalPerson struct {
value *Person
valid bool
}
func (o *OptionalPerson) Unwrap() *Person {
if !o.valid {
panic("unwrap a none value")
}
return o.value
}
func (o *OptionalPerson) IsSome() bool {
return o.valid
}
func (o *OptionalPerson) IsNone() bool {
return !o.valid
}
func (o *OptionalPerson) UnwrapOr(defaultValue *Person) *Person {
if !o.valid {
return defaultValue
}
return o.value
}
func Some(value *Person) OptionalPerson {
return OptionalPerson{
value: value,
valid: true,
}
}
func None() OptionalPerson {
return OptionalPerson{
valid: false,
}
}
Person 타입의 경우엔 OptionalPerson 타입으로 감싸지만, 패키지와 이름이 같기 떄문에 Some과 None 함수를 통해 생성할 수 있습니다.
show
imports
프로젝트 루트 폴더에서 jetti show --imports을 실행함으로 프로젝트 내부에서 각 패키지들이 의존하는 관계를 그래프로 그려줍니다.
그려진 그래프는 루트 폴더 내의 imports.svg 파일로 저장됩니다.