GitLab의 Go 표준 및 스타일 지침

이 문서는 Go 언어를 사용하는 GitLab 프로젝트를 위한 여러 지침과 모범 사례에 대해 설명합니다.

GitLab은 Ruby on Rails 위에 구축되었지만, 해당 작업에 적합한 프로젝트에서는 Go도 사용하고 있습니다. Go는 매우 강력한 언어로 여러 이점을 가지고 있으며, IO(디스크/네트워크 액세스), HTTP 요청, 병렬 처리 등이 많이 포함된 프로젝트에 가장 적합합니다. GitLab에는 Ruby on Rails와 Go가 모두 있는데, 이 둘 중 어느 것이 해당 작업에 가장 적합한지 신중하게 평가해야 합니다.

이 페이지는 다양한 경험을 기반으로 우리의 Go 지침을 정의하고 체계화하기 위한 것입니다. 다양한 표준으로 시작된 몇 가지 프로젝트는 여전히 특정 사항을 가질 수 있습니다. 관련 내용은 각각의 README.md 또는 PROCESS.md 파일에 설명되어 있습니다.

프로젝트 구조

Go 응용 프로그램 프로젝트를위한 기본 레이아웃에 따르면, 공식적인 Go 프로젝트 레이아웃은 없지만, Ben Johnson의 표준 패키지 레이아웃에서 좋은 제안이 있습니다.

다음은 영감을 주기 위한 몇 가지 GitLab Go 기반 프로젝트입니다.

Go 언어 버전

Go 업그레이드 문서는 GitLab이 Go 이진 지원을 관리하고 제공하는 개요를 제공합니다.

GitLab 구성 요소가 보다 최신 버전의 Go를 필요로 하는 경우, 버전 업그레이드 프로세스를 따르고 고객, 팀 또는 구성 요소에 부정적인 영향이 없도록합니다.

때로는 개별 프로젝트도 여러 버전의 Go를 지원하도록 빌드를 관리해야 합니다.

의존성 관리

Go는 의존성 관리를위한 소스 기반 전략을 사용합니다. 의존성은 소스 저장소에서 소스로 다운로드됩니다. 이는 의존성의 소스 저장소와 별도인 패키지 저장소에서 아티팩트로 다운로드되는 더 일반적인 아티팩트 기반 전략과 다릅니다.

Go는 1.11 이전에 버전 관리를위한 일급 지원을 가지고 있지 않았습니다. 해당 버전에서 Go 모듈과 의미론적 버전을 사용하도록 소개되었습니다. Go 1.12에서는 클라이언트와 소스 버전 관리 시스템 사이의 중간 역할을 할 수있는 모듈 프록시 및 의존성 다운로드의 무결성을 확인하는 데 사용할 수있는 체크섬 데이터베이스도 소개되었습니다.

자세한 내용은 Go의 의존성 관리를 참조하십시오.

코드 검토

우리는 Go 코드 검토 코멘트의 공통 원칙을 따릅니다.

검토자와 유지 관리자는 다음을 주의해야합니다:

  • defer 함수: 필요할 때 존재 여부와 err 확인 후에 실행되도록합니다.
  • 매개 변수로 의존성 주입
  • JSON으로 마샬링 할 때 void 구조체 ( null 대신 [] 생성)

보안

보안은 GitLab에서 가장 중요한 우선 순위입니다. 코드 검토 중에는 다음과 같은 가능한 보안 위반에 주의해야합니다:

  • text/template 사용시 XSS
  • Gorilla를 사용한 CSRF 보호
  • 알려진 취약점이없는 Go 버전 사용
  • 비밀 토큰 누설하지 말 것
  • SQL 인젝션

프로젝트에서 SAST의존성 스캐닝 (또는 최소한 gosec 분석기)를 실행하고 보안 요구 사항을 따르도록합니다.

웹 서버는 Secure와 같은 미들웨어를 이용할 수 있습니다.

리뷰어 찾기

우리의 많은 프로젝트는 전문 유지 관리자가없어 꽤 작습니다. 그래서 GitLab에는 공유 Go 리뷰어 풀이 있습니다. 리뷰어를 찾으려면 핸드북의 “공학 프로젝트” 페이지에서 “GitLab” 프로젝트의 “Go” 섹션을 사용하세요.

이 목록에 자신을 추가하려면 다음을 team.yml 파일의 프로필에 추가하고 관리자에게 검토 및 병합을 요청하십시오.

projects:
  gitlab: reviewer go

코드 스타일 및 형식

  • 전역 변수를 사용하지 마십시오. 심지어 패키지에서도 그렇게하면 패키지가 여러 번 포함된 경우 부작용을 일으킬 수 있습니다.
  • 커밋하기 전에 goimports를 사용하십시오.   goimports는 누락 된 것을 추가하고 미참조 된 것을 제거하는 것 외에도 Gofmt를 사용하여 Go 소스 코드를 자동으로 형식화하는 도구입니다.

  대부분의 편집기/IDE는 파일을 저장하기 전/후에 명령을 실행할 수 있으므로 goimports를 실행하도록 설정하여 모든 파일에 적용 할 수 있습니다. - 첫 번째 호출자 메서드 아래에 비공개 메서드를 배치하십시오.

자동 리딩

경고: registry.gitlab.com/gitlab-org/gitlab-build-images:golangci-lint-alpine의 사용은 16.10에서 사용 중단되었습니다.

golangci-lint의 상위 버전을 사용하십시오. 기본적으로 활성화/비활성화 된 린터 목록은 여기에서 확인할 수 있습니다.

Go 프로젝트에는 이러한 GitLab CI/CD 작업이 포함되어야합니다.

variables:
  GOLANGCI_LINT_VERSION: 'v1.56.2'
lint:
  image: golangci/golangci-lint:$GOLANGCI_LINT_VERSION
  stage: test
  script:
    - golangci-lint run --issues-exit-code 0 --print-issued-lines=false --out-format code-climate:gl-code-quality-report.json,line-number
  artifacts:
    reports:
      codequality: gl-code-quality-report.json
    paths:
      - gl-code-quality-report.json

프로젝트의 루트 디렉토리에 .golangci.yml를 추가하여 golangci-lint의 구성을 설정할 수 있습니다. golangci-lint에 대한 모든 옵션은 이 예제에 나와 있습니다.

재귀 포함을 사용할 수 있게되면 이러한 것과 같은 작업 템플릿을 공유 할 수 있게 될 것입니다.

Go GitLab 린터 플러그인은 gitlab-org/language-tools/go/linters 네임스페이스에서 유지 관리합니다.

도움말 텍스트 스타일 가이드

만약 귀하의 Go 프로젝트가 사용자를 위한 도움말 텍스트를 생성한다면, gitaly 프로젝트의 도움말 텍스트 스타일 가이드에 기술된 조언을 따르는 것을 고려해 보세요.

Dependencies

의존성은 최소한으로 유지되어야 합니다. 새로운 의존성을 도입할 때에는 승인 지침에 따라 합병 요청에서 논의되어야 합니다. 모든 프로젝트에 의존성 스캐닝이 활성화되어야 하며, 이를 통해 새로운 의존성의 보안 상태와 라이선스 호환성을 보장합니다.

모듈

Go 1.11 이상에서는 Go 모듈이라는 표준 의존성 시스템이 제공됩니다. 이를 통해 재현 가능한 빌드를 위해 의존성을 정의하고 잠그는 방법을 제공합니다. 가능한 경우 이를 사용해야 합니다.

Go 모듈을 사용할 때는 vendor/ 디렉터리가 존재해서는 안됩니다. 대신, Go는 프로젝트 빌드에 필요한 의존성이 필요할 때 자동으로 다운로드합니다. 이는 Ruby 프로젝트의 Bundler에서 의존성을 처리하는 방식과 일치하며, 합병 요청을 더 쉽게 검토할 수 있도록 합니다.

일부 경우, 다른 프로젝트의 CI 실행에 사용되도록 Go 프로젝트를 빌드하는 경우 vendor/ 디렉터리를 제거함으로써 코드를 반복적으로 다운로드해야 하므로, 요율 제한이나 네트워크 장애로 인한 일시적인 문제가 발생할 수 있습니다. 이러한 경우, 다운로드한 코드를 캐시하는 것이 좋습니다.

Go 버전 1.11.4 이전의 버전에서는 모듈 체크섬에 버그가 있었으므로, checksum mismatch 오류를 피하기 위해 이 버전 이상을 사용하도록 주의해야 합니다.

ORM

우리는 GitLab에서 객체-관계 매핑 라이브러리(ORM)를 사용하지 않습니다 (Ruby on Rails의 ActiveRecord는 예외입니다). 프로젝트는 이를 피하기 위해 서비스로 구성될 수 있습니다. pgx가 PostgreSQL 데이터베이스와 상호 작용하는 데 충분합니다.

마이그레이션

호스팅된 데이터베이스를 관리해야 하는 특별한 경우에는 ActiveRecord가 제공하는 마이그레이션 시스템을 사용해야 합니다. postgres 컨테이너에서 사용할 수 있도록 설계된 Journey와 같은 간단한 라이브러리는 새로운 버전이 배포될 때 자동으로 데이터를 마이그레이션하는 새로운 팟을 배포할 수 있습니다.

테스트

테스트 프레임워크

더 정교한 테스트 도구가 필요한 경우를 제외하고는 표준 라이브러리가 이미 모든 것을 시작하기에 충분하므로, 특정 라이브러리나 프레임워크를 사용하지 말아야 합니다. 그러나 보다 정교한 테스트 도구가 필요한 경우, 다음 외부 의존성을 사용할지 고려할 수 있습니다. 특정 라이브러리나 프레임워크를 사용하기로 결정할 경우에만 고려할 가치가 있는 외부 의존성은 다음과 같습니다.

서브테스트

가능한 경우 서브테스트를 사용하여 코드의 가독성과 테스트 결과를 향상시켜야 합니다.

테스트 결과 개선

테스트에서 예상 및 실제 값 비교 시, 구조체, 오류, 텍스트의 큰 부분 또는 JSON 문서를 비교할 때 가독성을 개선하기 위해 testify/require.Equal, testify/require.EqualError, testify/require.EqualValues 등을 사용해야 합니다.

type TestData struct {
    // ...
}

func FuncUnderTest() TestData {
    // ...
}

func Test(t *testing.T) {
    t.Run("FuncUnderTest", func(t *testing.T) {
        want := TestData{}
        got := FuncUnderTest()

        require.Equal(t, want, got) // 기대하는 값이 먼저 오고, 실제 값이 나중에 온다는 점을 유의하세요 ("차이" 의미론)
    })
}

테이블 기반 테스트

동일한 함수에 대한 여러 입력/출력 항목이 있는 경우 테이블 기반 테스트를 사용하는 것이 일반적으로 좋은 실천 방법입니다. 테이블 기반 테스트를 작성할 때는 다음 가이드라인을 따르는 것이 좋습니다. 이러한 가이드라인은 대부분 Go 표준 라이브러리 소스 코드에서 추출되었습니다. 합리적인 경우 이러한 가이드라인을 따르지 않는 것도 괜찮습니다.

테스트 케이스 정의

각 테이블 항목은 입력 및 기대 결과와 함께 때로는 테스트 결과를 쉽게 읽을 수 있도록 테스트 이름과 같은 추가 정보를 가진 완전한 테스트 케이스입니다.

테스트 케이스 내용

  • 이상적으로는 각 테스트 케이스에는 테스트 하위 테스트의 이름으로 사용할 고유 식별자 필드가 있어야 합니다. Go 표준 라이브러리에서 이는 일반적으로 name string 필드입니다.
  • 테스트 케이스를 명확히 설명할 때 want/expect/actual을 사용합니다.

변수 이름

  • 각 테이블 기반 테스트 맵/익명 구조체 슬라이스는 tests로 명명할 수 있습니다.
  • tests를 반복할 때 익명 구조체는 tt 또는 tc로 참조할 수 있습니다.
  • 테스트 설명이라면 name/testName/tn으로 지칭할 수 있습니다.

벤치마크

많은 IO 또는 복잡한 작업을 처리하는 프로그램은 항상 벤치마크를 포함하여 성능을 시간이 지남에 따라 일관되게 보장해야 합니다.

에러 처리

컨텍스트 추가

에러를 반환하기 전에 컨텍스트를 추가하는 것이 도움이 될 수 있습니다. 단순히 에러만 반환하는 것이 아니라, 프로그램이 에러 상태에 들어갔을 때 무엇을 시도했는지를 개발자가 이해할 수 있도록 합니다. 이는 디버깅을 훨씬 쉽게 만듭니다.

예를 들어:

// 에러 감싸기
return nil, fmt.Errorf("캐시 가져오기 %s: %w", f.Name, err)

// 컨텍스트만 추가
return nil, fmt.Errorf("캐시 저장하기 %s: %v", f.Name, err)

컨텍스트를 추가할 때 주의할 사항 몇 가지:

  • 호출자에게 기본 에러를 노출할지 여부를 결정하세요. 그렇다면 %w를 사용하고, 그렇지 않다면 %v를 사용할 수 있습니다.
  • ‘실패했다’, ‘에러가 발생했다’, ‘하지 않았다’와 같은 단어를 사용하지 마십시오. 이미 에러이므로 무엇이 실패했는지 설명하는 것이 더 좋습니다.
  • 에러 문자열은 대문자로 시작하거나 마침표나 새 줄로 끝나면 안 됩니다. 이에 대해 확인하려면 golint를 사용할 수 있습니다.

네이밍

  • sentinel 에러를 사용할 때는 항상 ErrXxx와 같이 명명해야 합니다.
  • 새로운 에러 유형을 만들 때는 항상 XxxError와 같이 명명해야 합니다.

에러 유형 확인

  • 에러 동등성을 확인하려면 ==를 사용하지 말고, 대신 (Go 버전이 1.13 이상인 경우) errors.Is를 사용하십시오.
  • 에러가 특정 유형인지 확인하려면 타입 어설션을 사용하지 말고, 대신 (Go 버전이 1.13 이상인 경우) errors.As를 사용하십시오.

에러 처리 작업을 위한 참조 자료

CLI

모든 Go 프로그램은 명령줄에서 시작됩니다. cli는 명령줄 앱을 만드는 데 유용한 패키지입니다. 이 패키지는 프로젝트가 데몬인지 간단한 CLI 도구인지 여부에 관계없이 사용해야 합니다. 플래그를 환경 변수에 직접 매핑할 수 있어 프로그램과의 모든 가능한 명령행 상호작용을 동시에 문서화하고 중앙화할 수 있습니다. os.GetEnv를 사용하지 말고, 이는 코드 안에서 변수를 숨깁니다.

라이브러리

LabKit

LabKit은 Go 서비스를 위한 공통 라이브러리를 유지하는 장소입니다. LabKit을 사용한 예시는 workhorsegitaly을 참조하세요. LabKit은 세 가지 관련 기능을 내보냅니다:

이는 Workhorse, Gitaly 및 가능한 다른 Go 서버 간에 일관된 구현을 제공하는 얇은 추상화를 제공합니다. 예를 들어 gitlab.com/gitlab-org/labkit/tracing의 경우, 응용 프로그램 코드를 수정하지 않고도 Opentracing에서 Zipkin 또는 Go kit의 추적 래퍼를 사용하도록 전환할 수 있으며 여전히 동일한 일관된 구성 메커니즘(즉, GITLAB_TRACING 환경 변수)을 유지할 수 있습니다.

구조화된 (JSON) 로깅

모든 이진 파일에는 이상적으로 구조화된(JSON) 로깅이 있어야 합니다. 이는 로그를 검색하고 필터링하는 데 도움이 됩니다. LabKit은 Logrus를 통해 구조화된 로깅을 추상화합니다. 우리의 모든 인프라가 이를 전제로 하기 때문에 JSON 형식의 구조화된 로깅을 사용합니다. Logrus를 사용할 때 내장된 JSON 포매터를 사용하여 구조화된 로깅을 활성화할 수 있습니다. 이는 우리의 루비 어플리케이션에서 사용하는 로깅 유형과 일치합니다.

Logrus 사용 방법

Logrus 패키지를 사용할 때는 몇 가지 지침을 따르어야 합니다:

  • 에러를 출력할 때 WithError를 사용하세요. 예를 들어, logrus.WithError(err).Error("Failed to do something").
  • 구조화된 로깅을 사용하기 때문에 그 코드 경로의 컨텍스트에서 필드를 로깅할 수 있으며, 예를 들어 WithFieldWithFields를 사용하여 요청의 URI와 같은 필드를 로깅할 수 있습니다. 예를 들어, logrus.WithField("file", "/app/go").Info("Opening dir"). 여러 키를 로깅해야 하는 경우 항상 WithFields를 여러 번 호출하는 대신에 사용하세요.

Dockerfiles

모든 프로젝트는 저장소 루트에 Dockerfile을 가져야 하며, 프로젝트를 빌드하고 실행해야 합니다. Go 프로그램은 정적 이진 파일이기 때문에 외부 종속성이 필요하지 않으며, 최종 이미지의 셸은 불필요합니다. 우리는 다중 단계 빌드를 권장합니다:

  • 사용자가 올바른 Go 버전과 종속성으로 프로젝트를 빌드할 수 있게 합니다.
  • Scratch에서 파생된 작은, 자체 포함 이미지를 생성합니다.

생성된 Docker 이미지는 이동 가능한 명령을 만들기 위해 프로그램이 Entrypoint에 있어야 합니다. 이렇게 하면 누구나 이미지를 실행할 수 있으며, 매개변수 없이 실행하면 도움말 메시지가 표시됩니다(cli가 사용된 경우).

Secure Team 표준 및 스타일 가이드

다음은 Secure Team에 특화된 몇 가지 스타일 가이드입니다.

코드 스타일과 형식

커밋하기 전에 goimports -local gitlab.com/gitlab-org을 사용하세요. goimportsGofmt를 사용하여 Go 소스 코드를 자동으로 형식화하는 도구로, import 줄을 형식화하고 누락된 줄을 추가하고 참조되지 않는 줄을 제거합니다. -local gitlab.com/gitlab-org 옵션을 사용하여 goimports는 로컬 참조 패키지를 외부 패키지와 별도로 그룹화합니다. 자세한 내용은 Go 위키의 imports section을 참조하세요. 대부분의 편집기/IDE는 파일을 저장하기 전/후에 명령을 실행할 수 있으며, goimports -local gitlab.com/gitlab-org을 설정하여 파일을 저장할 때마다 적용되도록 설정할 수 있습니다.

브랜치 명명

GitLab branch name rules에 추가하여, 브랜치 이름에는 a-z, 0-9, 또는 - 문자만 사용하세요. 이 제한은 특정 문자(예: 슬래시 /)를 포함하는 브랜치 이름은 go get이 예상대로 작동하지 않기 때문에 적용됩니다. ``` $ go get -u gitlab.com/gitlab-org/security-products/analyzers/report/v3@some-user/some-feature

go get: gitlab.com/gitlab-org/security-products/analyzers/report/v3@some-user/some-feature: invalid version: version “some-user/some-feature” invalid: disallowed version string 브랜치 이름에 슬래시가 포함되면 보다 유연하지 못하게 커밋 SHA로 참조해야 합니다(예:). $ go get -u gitlab.com/gitlab-org/security-products/analyzers/report/v3@5c9a4279fa1263755718cf069d54ba8051287954

go: downloading gitlab.com/gitlab-org/security-products/analyzers/report/v3 v3.15.3-0.20221012172609-5c9a4279fa12 … ```

슬라이스 초기화

슬라이스를 초기화하는 경우, 가능한 경우에는 용량을 제공하여 추가적인 할당을 피하세요.

하지 마십시오:

var s2 []string
for _, val := range s1 {
    s2 = append(s2, val)
}

해 주세요:

s2 := make([]string, 0, len(s1))
for _, val := range s1 {
    s2 = append(s2, val)
}

새로운 슬라이스를 만들 때 make에 용량을 지정하지 않으면, append는 값이 백킨 배열에 들어가지 못한다면 계속해서 슬라이스의 백킨 배열 크기를 조정합니다. 용량을 제공하면 할당을 최소화할 수 있습니다. 이를 자동으로 확인하기 위해 prealloc 고언체-린트 룰을 사용하는 것이 권장됩니다.

분석기 테스트

일반적으로 Secure 분석기에는 convert 함수 가 있어서 SAST/DAST 스캐너 보고서를 GitLab 보안 보고서 로 변환합니다. convert 함수에 대한 테스트를 작성할 때, 분석기 저장소의 루트에 있는 testdata 디렉토리를 사용하여 test fixtures를 활용해야 합니다. testdata 디렉토리에는 두 개의 하위 디렉터리가 있어야 합니다: expectreports. reports 디렉토리에는 테스트 설정 중 convert 함수에 전달되는 샘플 SAST/DAST 스캐너 보고서가 포함되어야 합니다. expect 디렉토리에는 convert가 반환하는 예상 GitLab Security 보고서가 있어야 합니다. 예시는 Secret Detection을 참조하세요. example.

스캐너 보고서가 작으면, 35줄 미만이라면 testdata 디렉토리를 사용하는 대신 보고서를 inline할 수 있습니다.

테스트 차이점

테스트에서 큰 구조체를 비교할 때 go-cmp 패키지를 사용해야 합니다. 이는 두 구조체가 차이가 나는 부분에 대한 특정 차이점을 출력할 수 있도록 만들어줍니다. 전체 구조체가 모두 로그에 출력되는 대신 두 구조체가 차이가 나는 부분만을 출력할 수 있게 합니다. 아래는 작은 예시입니다:

package main

import (
  "reflect"
  "testing"

  "github.com/google/go-cmp/cmp"
)

type Foo struct {
  Desc  Bar
  Point Baz
}

type Bar struct {
  A string
  B string
}

type Baz struct {
  X int
  Y int
}

func TestHelloWorld(t *testing.T) {
  want := Foo{
    Desc:  Bar{A: "a", B: "b"},
    Point: Baz{X: 1, Y: 2},
  }

  got := Foo{
    Desc:  Bar{A: "a", B: "b"},
    Point: Baz{X: 2, Y: 2},
  }

  t.Log("reflect comparison:")
  if !reflect.DeepEqual(got, want) {
    t.Errorf("Wrong result. want:\n%v\nGot:\n%v", want, got)
  }

  t.Log("cmp comparison:")
  if diff := cmp.Diff(want, got); diff != "" {
    t.Errorf("Wrong result. (-want +got):\n%s", diff)
  }
}

위 출력은 구조체를 비교할 때 go-cmp를 사용할 때 각 구조체가 어떻게 다르게 출력되는지를 보여줍니다. 차이가 크지 않으면 확인할 수 있지만, 데이터가 커지면 어려워집니다.

  main_test.go:36: reflect comparison:
  main_test.go:38: Wrong result. want:
      {{a b} {1 2}}
      Got:
      {{a b} {2 2}}
  main_test.go:41: cmp comparison:
  main_test.go:43: Wrong result. (-want +got):
        main.Foo{
              Desc: {A: "a", B: "b"},
              Point: main.Baz{
      -               X: 1,
      +               X: 2,
                      Y: 2,
              },
        }

개발 문서로 돌아가기.