쿼카러버의 기술 블로그

[Go] struct tag 기초 : field name mapping, data validation (필드 이름 매핑, 데이터 검증 간단하게 살펴보기) 본문

[Golang]

[Go] struct tag 기초 : field name mapping, data validation (필드 이름 매핑, 데이터 검증 간단하게 살펴보기)

quokkalover 2022. 3. 1. 14:19

Golang으로 프로그래밍을 하다보면 가장 많이 사용하고, 사용되고 있는 타입이 struct다.

  • Json, XML, Yaml파일을 마샬/언마샬(Marshal / Unmarshal) 하든,
  • 웹서버 개발을 한다면 http request / response를 사전 정의 해두든,
  • 프로그래머가 원하는 객체를 만들든,

굉장히 다양한 용도로 사용된다.

 

본 글에서는 struct중에서도 유용하게 쓰이는 feature중 하나인 Field tag에 대해 알아보려고 한다.

 

Field tag란 아래 예시처럼 struct를 정의할 때,

type User struct {
  Id        int       `json:"id"`
  Name      string    `json:"name"`
  Bio       string    `json:"about,omitempty"`
  Active    bool      `json:"active"`
  Admin     bool      `json:"-"`
  CreatedAt time.Time `json:"created_at"`
}
  • field에 대한 metadata를 정의 : marshalling할 때 출력될 필드 이름 사전 정의
  • data validation : 데이터베이스에 값을 적재하기 전에 의도한 값이 입력됐는지 검증

을 설정하거나 할 때 사용한다.

 

Field name mapping

Field tag를 가장 많이 활용하는 부분이 field name mapping이다. 외부 서비스와 통신하거나, 내부에서 data를 변환하거나 할 때 매우 편리한 기능이다.

field name mapping이란, 해당 struct를 Marshalling할 때 해당 필드의 이름을 미리 매핑시켜두는 것을 의미한다. 바로 예시로 살펴보자.

type User struct {
    Id        int
    Name      string
    Bio       string
    Active    bool
    Admin     bool
    CreatedAt time.Time
}

위와 같은 User struct를 그냥 json.Marshal를 호출해서 마샬링하면

{"Id":0,"Name":"","Bio":"","Active":false,"Admin":false,"CreatedAt":"0001-01-01T00:00:00Z"}

위와 같이 기본적으로

  • struct내의 모든 정보가 출력
  • 첫 번째 글자를 대문자로 설정한 필드 이름을 출력한다.

하지만 아래와 같이 backtick(```)으로 감싸서 json tagging을 해주면, JSON encoder가 struct필드를 조회해서, 사전에 user-defined된 필드 이름이 있다면, 해당 이름으로 변환하여 출력한다.

type TaggedUser struct {
    Id        int       `json:"id"`
    Name      string    `json:"name"`
    Bio       string    `json:"about,omitempty"`
    Active    bool      `json:"active"`
    Admin     bool      `json:"-"`
    CreatedAt time.Time `json:"created_at"`
}
{"id":0,"name":"","active":false,"created_at":"0001-01-01T00:00:00Z"}

출력결과를 보면 아래와 같은 특징이 있음을 확인할 수 있다.

  • 미리 정의된 이름으로 출력
  • omitempty로 태그된 필드는 값이 없으면 출력하지 않음
  • -로 태그된 필드는 출력되지 않음.

 

validation을 위한 tag processor 구현 해보기

위 처럼 태그가 어떻게 동작하는지 확인하기 위해, tag processor를 직접 구현해보자.

 

tag processor를 구현하는데 꼭 필요한 패키지가 바로 reflect 패키지다. reflect패키지는 go의 자료형들에 대한 기본 정보를 추출할 수 있게 해주고, 이를 활용해서 struct를 정의할 때 사용한 tag를 추출할 것이다.

 

일반적으로 데이터베이스에 값을 저장하기 전에, data validation을 하게 되는데, 본 예제에서는 field 타입에 따라 간단한 data validation을 해주는 tag processor를 구현할 것이다.

 

일단 기본적으로 reflect가 어떻게 활용되는지 살펴보자.

package main

import (
    "fmt"
    "reflect"
)

// Name of the struct tag used in examples
const tagName = "validate"

type User struct {
    Id    int    `validate:"-"`
    Name  string `validate:"presence,min=2,max=32"`
    Email string `validate:"email,required"`
}

func main() {

    user := User{
        Id:    1,
        Name:  "John Doe",
        Email: "john@example",
    }

    // TypeOf returns the reflection Type that represents the dynamic type of variable.
    // If variable is a nil interface value, TypeOf returns nil.
    t := reflect.TypeOf(user)
    // Get the type and kind of our user variable
    fmt.Println("Type:", t.Name())
    fmt.Println("Kind:", t.Kind())

    // Iterate over all available fields and read the tag value
    for i := 0; i < t.NumField(); i++ {

        // Get the field, returns https://golang.org/pkg/reflect/#StructField
        field := t.Field(i)

        // Get the field tag value
        tag := field.Tag.Get(tagName)
        fmt.Printf("%d. %v (%v), tag: '%v'\n", i+1, field.Name, field.Type.Name(), tag)
    }
}

위 예시를 보면 알듯이 field.Tag.Get을 통해서 미리 설정해둔 tagName을 가진 필드들의 tag를 가져올 수 있다.

Type: User
Kind: struct
1. Id (int), tag: '-'
2. Name (string), tag: 'presence,min=2,max=32'
3. Email (string), tag: 'email,required'

자 그럼 이렇게 tag를 추출할 수 있게 됐으면, Validator는 어떻게 만드는지 감이 올 것이다. 한번 해보자. 본 예제에서는 일단 Validator라는 인터페이스를 구현한 numeric, string, email validator를 구현해볼 것이다.

 

 

일단 이해를 위해 numeric 필드에 대한 validator를 구현해보자.

 

먼저 Validator interface를 정의한다.

// Generic data validator.
type Validator interface {
    // Validate method performs validation and returns result and optional error.
    Validate(interface{}) (bool, error)
}

 

Numeric Validator를 구현한다.

  • 이 validator는 필드 내 값이 validator에 정의한 min, max사이의 값인지 확인한다.

// NumberValidator performs numerical value validation.
// Its limited to int type for simplicity.
type NumberValidator struct {
    Min int
    Max int
}

func (v NumberValidator) Validate(val interface{}) (bool, error) {
    num := val.(int)
    if num < v.Min {
        return false, fmt.Errorf("should be greater than %v", v.Min)
    }
    if v.Max >= v.Min && num > v.Max {
        return false, fmt.Errorf("should be less than %v", v.Max)
    }
    return true, nil
}

tag에 따라 어떤 Validator를 사용할 것인지 찾아주는 메소드를 구현한다.

// Returns validator struct corresponding to validation type
func getValidatorFromTag(tag string) Validator {
    args := strings.Split(tag, ",")
    switch args[0] {
    case "number":
        validator := NumberValidator{}
        fmt.Sscanf(strings.Join(args[1:], ","), "min=%d,max=%d", &validator.Min, &validator.Max)
        return validator
    return DefaultValidator{}
}

Validator를 가져다가 struct필드들에 대해 validation을 실행한다.

// Performs actual data validation using validator definitions on the struct
func validateStruct(s interface{}) []error {
    errs := []error{}
    // ValueOf returns a Value representing the run-time data
    v := reflect.ValueOf(s)
    for i := 0; i < v.NumField(); i++ {
        // Get the field tag value
        tag := v.Type().Field(i).Tag.Get(tagName)
        // Skip if tag is not defined or ignored
        if tag == "" || tag == "-" {
            continue
        }
        // Get a validator that corresponds to a tag
        validator := getValidatorFromTag(tag)
        // Perform validation
        valid, err := validator.Validate(v.Field(i).Interface())
        // Append error to results
        if !valid && err != nil {
            errs = append(errs, fmt.Errorf("%s %s", v.Type().Field(i).Name, err.Error()))
        }
    }
    return errs
}

 

이제 위 코드들을 종합하고, string, numeric, email Validator를 구현한 코드는 아래와 같다.

package main

import (
    "fmt"
    "reflect"
    "regexp"
    "strings"
)

// Name of the struct tag used in examples.
const tagName = "validate"

// Regular expression to validate email address.
var mailRe = regexp.MustCompile(`\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z`)

// Generic data validator.
type Validator interface {
    // Validate method performs validation and returns result and optional error.
    Validate(interface{}) (bool, error)
}

// DefaultValidator does not perform any validations.
type DefaultValidator struct {
}

func (v DefaultValidator) Validate(val interface{}) (bool, error) {
    return true, nil
}

// StringValidator validates string presence and/or its length.
type StringValidator struct {
    Min int
    Max int
}

func (v StringValidator) Validate(val interface{}) (bool, error) {
    l := len(val.(string))
    if l == 0 {
        return false, fmt.Errorf("cannot be blank")
    }
    if l < v.Min {
        return false, fmt.Errorf("should be at least %v chars long", v.Min)
    }
    if v.Max >= v.Min && l > v.Max {
        return false, fmt.Errorf("should be less than %v chars long", v.Max)
    }
    return true, nil
}

// NumberValidator performs numerical value validation.
// Its limited to int type for simplicity.
type NumberValidator struct {
    Min int
    Max int
}

func (v NumberValidator) Validate(val interface{}) (bool, error) {
    num := val.(int)
    if num < v.Min {
        return false, fmt.Errorf("should be greater than %v", v.Min)
    }
    if v.Max >= v.Min && num > v.Max {
        return false, fmt.Errorf("should be less than %v", v.Max)
    }
    return true, nil
}

// EmailValidator checks if string is a valid email address.
type EmailValidator struct {
}

func (v EmailValidator) Validate(val interface{}) (bool, error) {
    if !mailRe.MatchString(val.(string)) {
        return false, fmt.Errorf("is not a valid email address")
    }
    return true, nil
}

// getValidatorFromTag returns validator struct corresponding to validation type
func getValidatorFromTag(tag string) Validator {
    args := strings.Split(tag, ",")
    switch args[0] {
    case "number":
        validator := NumberValidator{}
        fmt.Sscanf(strings.Join(args[1:], ","), "min=%d,max=%d", &validator.Min, &validator.Max)
        return validator
    case "string":
        validator := StringValidator{}
        fmt.Sscanf(strings.Join(args[1:], ","), "min=%d,max=%d", &validator.Min, &validator.Max)
        return validator
    case "email":
        return EmailValidator{}
    }
    return DefaultValidator{}
}

// valdiateStruct performs actual data validation using validator definitions on the struct
// and returns errors while validating each field
func validateStruct(s interface{}) []error {
    errs := []error{}
    // ValueOf returns a Value representing the run-time data
    v := reflect.ValueOf(s)
    for i := 0; i < v.NumField(); i++ {
        // Get the field tag value
        tag := v.Type().Field(i).Tag.Get(tagName)
        // Skip if tag is not defined or ignored
        if tag == "" || tag == "-" {
            continue
        }
        // Get a validator that corresponds to a tag
        validator := getValidatorFromTag(tag)

        // Perform validation
        // Interface() give field in interface type so that we can pass for validation
        valid, err := validator.Validate(v.Field(i).Interface())
        // Append error to results
        if !valid && err != nil {
            errs = append(errs, fmt.Errorf("%s %s", v.Type().Field(i).Name, err.Error()))
        }
    }
    return errs
}

type User struct {
    Id    int    `validate:"number,min=1,max=1000"`
    Name  string `validate:"string,min=2,max=10"`
    Bio   string `validate:"string"`
    Email string `validate:"email"`
}

func main() {
    user := User{
        Id:    0,
        Name:  "superlongstring",
        Bio:   "",
        Email: "foobar",
    }
    fmt.Println("Errors:")
    for i, err := range validateStruct(user) {
        fmt.Printf("\t%d. %s\n", i+1, err.Error())
    }
}

위 코드를 실행하고나면

Errors:
  1. Id should be greater than 1
  2. Name should be less than 10 chars long
  3. Bio cannot be blank
  4. Email is not a valid email address

위와 같이 필드들에 대해 validation 에러들이 출력된다.

 

위와 같이 기본적인 tag를 활용한 validator를 구현해봤다. 이 외에도 한번에 여러 개의 validator를 구현할 수도 있다. gin framework의 validator도 이것과 비슷하게 구현돼있고, tag의 활용방법은 무궁무진하니 잘 활용해보자!

 

참고자료

  • gorm — Database ORM for SQLite, Postgres and MySQL.
  • govalidator — Package of validators and sanitizers for strings, numerics, slices and structs.
  • mgo — The MongoDB database driver for Go.

https://medium.com/swlh/custom-struct-field-tags-and-validation-in-golang-9a7aeedcdc5b

Comments