0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[自作ツール]ghstatを作ってで学んだGitHub API実践テクニック

Posted at

前回、GitHub統計ツール「ghstat」を作りながらGitHub APIの基本を学びました。今回は、その続編としてもっと深くGitHub APIを掘り下げます。

実際に遭遇した問題、その解決策、そして「これ知ってたら最初から楽だったのに...」というTipsを惜しみなくシェアします。

GitHub API v3 vs GraphQL API

GitHub APIには2つのバージョンがあります:

REST API (v3) - 今回使ったやつ

// シンプル!分かりやすい!
repos, resp, err := client.Repositories.List(ctx, "", nil)

メリット:

  • 直感的で分かりやすい
  • ライブラリが充実
  • ドキュメントが豊富

デメリット:

  • データの取得に複数リクエストが必要
  • Over-fetching(不要なデータも取得)
  • レート制限が厳しい(5000リクエスト/時)

GraphQL API (v4) - 新しいやつ

query {
  viewer {
    repositories(first: 100) {
      nodes {
        name
        stargazerCount
        languages(first: 10) {
          edges {
            node {
              name
            }
          }
        }
      }
    }
  }
}

メリット:

  • 1回のリクエストで必要なデータすべて取得
  • レート制限が緩い(ポイント制)
  • Under-fetching/Over-fetching がない

デメリット:

  • 学習コストが高い
  • クエリの設計が難しい

結論:

  • REST API - シンプルなツールならこれで十分
  • GraphQL API - 大規模アプリや複雑なデータ取得には最適

ghstatは最初REST APIで作りましたが、パフォーマンスが問題になったらGraphQLに移行する予定です。

レート制限との正しい付き合い方

これが一番重要。GitHub APIには厳しいレート制限があります。

レート制限の種類

認証方法 制限
認証なし 60リクエスト/時
Personal Access Token 5,000リクエスト/時
GitHub App 15,000リクエスト/時
GraphQL API 5,000ポイント/時

リアルタイムで制限を確認

func (c *Client) GetRateLimit() (*RateLimitInfo, error) {
    rate, _, err := c.client.RateLimits(c.ctx)
    if err != nil {
        return nil, err
    }
    
    return &RateLimitInfo{
        Limit:     rate.Core.Limit,
        Remaining: rate.Core.Remaining,
        Reset:     rate.Core.Reset.Time,
    }, nil
}

// 使用例
func (c *Client) safeAPICall() error {
    info, _ := c.GetRateLimit()
    
    if info.Remaining < 100 {
        waitTime := time.Until(info.Reset)
        log.Printf("⚠️  API limit low (%d remaining), waiting %v", 
            info.Remaining, waitTime)
        time.Sleep(waitTime)
    }
    
    // APIコール実行
    return c.actualAPICall()
}

レスポンスヘッダーからも取得できる

APIレスポンスには必ずレート制限情報が含まれています:

repos, resp, err := client.Repositories.List(ctx, "", nil)

// レスポンスヘッダーから取得
remaining := resp.Header.Get("X-RateLimit-Remaining")
reset := resp.Header.Get("X-RateLimit-Reset")

log.Printf("Remaining: %s, Reset: %s", remaining, reset)

実践Tips:

  1. 早めにチェック - 残り100リクエストで警告
  2. リセット時刻を待つ - 無駄な失敗を避ける
  3. キャッシュを活用 - 同じデータは再取得しない

ページネーション完全ガイド

GitHubは大量データをページングで返します。正しく扱わないと、データが取れません。

基本パターン

func (c *Client) ListAllRepositories() ([]*github.Repository, error) {
    opt := &github.RepositoryListOptions{
        ListOptions: github.ListOptions{
            PerPage: 100,  // 最大100件/ページ
        },
        Sort:      "updated",
        Direction: "desc",
    }
    
    var allRepos []*github.Repository
    
    for {
        repos, resp, err := c.client.Repositories.List(c.ctx, "", opt)
        if err != nil {
            return nil, fmt.Errorf("failed to list repos: %w", err)
        }
        
        allRepos = append(allRepos, repos...)
        
        // 次ページがなければ終了
        if resp.NextPage == 0 {
            break
        }
        
        opt.Page = resp.NextPage
    }
    
    return allRepos, nil
}

プログレスバー付きページネーション

ユーザーに進捗を見せると親切:

func (c *Client) ListAllRepositoriesWithProgress() ([]*github.Repository, error) {
    // まず総数を取得
    user, _, _ := c.client.Users.Get(c.ctx, "")
    totalRepos := user.GetPublicRepos() + user.GetTotalPrivateRepos()
    
    // プログレスバー作成
    bar := progressbar.NewOptions(totalRepos,
        progressbar.OptionSetDescription("Fetching repositories..."),
    )
    
    opt := &github.RepositoryListOptions{
        ListOptions: github.ListOptions{PerPage: 100},
    }
    
    var allRepos []*github.Repository
    
    for {
        repos, resp, err := c.client.Repositories.List(c.ctx, "", opt)
        if err != nil {
            return nil, err
        }
        
        allRepos = append(allRepos, repos...)
        bar.Add(len(repos))  // 進捗更新
        
        if resp.NextPage == 0 {
            break
        }
        
        opt.Page = resp.NextPage
    }
    
    return allRepos, nil
}

並列ページネーション(上級者向け)

複数リポジトリのコミット取得を並列化:

func (c *Client) GetCommitsParallel(repos []*Repository) map[string][]*Commit {
    results := make(map[string][]*Commit)
    var mu sync.Mutex
    var wg sync.WaitGroup
    
    // セマフォでAPI制限を守る
    sem := make(chan struct{}, 10)  // 同時10リクエストまで
    
    for _, repo := range repos {
        wg.Add(1)
        go func(r *Repository) {
            defer wg.Done()
            
            sem <- struct{}{}        // セマフォ取得
            defer func() { <-sem }() // セマフォ解放
            
            commits, err := c.getRepoCommits(r.Name)
            if err != nil {
                return
            }
            
            mu.Lock()
            results[r.Name] = commits
            mu.Unlock()
        }(repo)
    }
    
    wg.Wait()
    return results
}

注意点:

  • セマフォで同時リクエスト数を制限
  • Mutexで結果マップを保護
  • エラーは無視(一部失敗しても続行)

エラーハンドリングの極意

GitHub APIのエラーは種類が多い。適切に処理しないと、ユーザーが困ります。

エラーの種類と対処法

func handleGitHubError(err error) error {
    if err == nil {
        return nil
    }
    
    // GitHub APIエラーの型アサーション
    if ghErr, ok := err.(*github.ErrorResponse); ok {
        switch ghErr.Response.StatusCode {
        case 401:
            return fmt.Errorf("❌ 認証エラー: トークンが無効です")
        case 403:
            if ghErr.Message == "API rate limit exceeded" {
                return fmt.Errorf("❌ API制限超過: %v にリセットされます", 
                    ghErr.Rate.Reset.Time.Format("15:04:05"))
            }
            return fmt.Errorf("❌ アクセス拒否: %s", ghErr.Message)
        case 404:
            return fmt.Errorf("❌ リソースが見つかりません")
        case 422:
            return fmt.Errorf("❌ バリデーションエラー: %s", ghErr.Message)
        default:
            return fmt.Errorf("❌ APIエラー (%d): %s", 
                ghErr.Response.StatusCode, ghErr.Message)
        }
    }
    
    // ネットワークエラー
    if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
        return fmt.Errorf("❌ タイムアウト: ネットワークが遅いか、不安定です")
    }
    
    // その他のエラー
    return fmt.Errorf("❌ 予期しないエラー: %w", err)
}

// 使用例
repos, _, err := client.Repositories.List(ctx, "", nil)
if err != nil {
    return handleGitHubError(err)
}

リトライロジック

一時的なエラーは自動リトライ:

func (c *Client) callWithRetry(fn func() error) error {
    maxRetries := 3
    backoff := time.Second
    
    for i := 0; i < maxRetries; i++ {
        err := fn()
        if err == nil {
            return nil
        }
        
        // リトライ可能なエラーかチェック
        if !isRetryable(err) {
            return err
        }
        
        if i < maxRetries-1 {
            log.Printf("⚠️  リトライ %d/%d (待機: %v)", 
                i+1, maxRetries, backoff)
            time.Sleep(backoff)
            backoff *= 2  // Exponential backoff
        }
    }
    
    return fmt.Errorf("最大リトライ回数を超えました")
}

func isRetryable(err error) bool {
    if ghErr, ok := err.(*github.ErrorResponse); ok {
        // 5xx エラーはリトライ可能
        return ghErr.Response.StatusCode >= 500
    }
    
    // ネットワークエラーもリトライ可能
    if netErr, ok := err.(net.Error); ok {
        return netErr.Timeout() || netErr.Temporary()
    }
    
    return false
}

認証トークンのベストプラクティス

トークンの扱いは超重要。間違えると大惨事です。

1. トークンは絶対にハードコードしない

// ❌ 絶対ダメ!
const GITHUB_TOKEN = "ghp_xxxxxxxxxxxxxxxxxxxx"

// ✅ 環境変数から
token := os.Getenv("GITHUB_TOKEN")

// ✅ または設定ファイルから(.gitignore必須)
cfg, _ := config.Load()
token := cfg.GitHubToken

2. トークンのスコープは最小限に

Personal Access Tokenを作るとき、必要な権限だけ付与:

ghstatに必要なスコープ:
✅ repo (プライベートリポジトリアクセス用)
✅ read:user (ユーザー情報取得用)
❌ admin:org (不要!)
❌ delete_repo (危険!)

3. トークンの有効期限を設定

# トークン作成時
Expiration: 90 days  # 推奨

4. 複数トークンの切り替え

type Client struct {
    client *github.Client
    ctx    context.Context
}

// トークンを切り替えて新しいクライアント作成
func NewClientWithToken(token string) (*Client, error) {
    if token == "" {
        return nil, errors.New("token required")
    }
    
    ctx := context.Background()
    ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
    tc := oauth2.NewClient(ctx, ts)
    
    return &Client{
        client: github.NewClient(tc),
        ctx:    ctx,
    }, nil
}

// 使用例:複数アカウントの統計を取得
func compareAccounts(token1, token2 string) {
    client1, _ := NewClientWithToken(token1)
    client2, _ := NewClientWithToken(token2)
    
    stats1 := client1.GetStats()
    stats2 := client2.GetStats()
    
    // 比較処理...
}

キャッシュ戦略

API呼び出しを減らす一番効果的な方法はキャッシュです。

1. メモリキャッシュ(シンプル)

type CachedClient struct {
    client *Client
    cache  map[string]interface{}
    mutex  sync.RWMutex
    ttl    time.Duration
}

func NewCachedClient(client *Client) *CachedClient {
    return &CachedClient{
        client: client,
        cache:  make(map[string]interface{}),
        ttl:    5 * time.Minute,  // 5分間キャッシュ
    }
}

func (c *CachedClient) GetRepositories() ([]*Repository, error) {
    key := "repos"
    
    // キャッシュチェック
    c.mutex.RLock()
    if cached, ok := c.cache[key]; ok {
        c.mutex.RUnlock()
        return cached.([]*Repository), nil
    }
    c.mutex.RUnlock()
    
    // キャッシュミス:APIコール
    repos, err := c.client.ListRepositories()
    if err != nil {
        return nil, err
    }
    
    // キャッシュに保存
    c.mutex.Lock()
    c.cache[key] = repos
    c.mutex.Unlock()
    
    // TTL後に削除
    time.AfterFunc(c.ttl, func() {
        c.mutex.Lock()
        delete(c.cache, key)
        c.mutex.Unlock()
    })
    
    return repos, nil
}

2. ファイルキャッシュ(永続化)

type FileCache struct {
    dir string
}

func NewFileCache() *FileCache {
    home, _ := os.UserHomeDir()
    dir := filepath.Join(home, ".cache", "ghstat")
    os.MkdirAll(dir, 0755)
    
    return &FileCache{dir: dir}
}

func (c *FileCache) Get(key string, v interface{}) error {
    path := filepath.Join(c.dir, key+".json")
    
    // ファイル存在チェック
    info, err := os.Stat(path)
    if err != nil {
        return err
    }
    
    // 24時間以上古いキャッシュは無効
    if time.Since(info.ModTime()) > 24*time.Hour {
        return errors.New("cache expired")
    }
    
    data, err := os.ReadFile(path)
    if err != nil {
        return err
    }
    
    return json.Unmarshal(data, v)
}

func (c *FileCache) Set(key string, v interface{}) error {
    path := filepath.Join(c.dir, key+".json")
    
    data, err := json.MarshalIndent(v, "", "  ")
    if err != nil {
        return err
    }
    
    return os.WriteFile(path, data, 0644)
}

// 使用例
cache := NewFileCache()

// キャッシュから取得
var repos []*Repository
if err := cache.Get("repos", &repos); err != nil {
    // キャッシュミス:APIから取得
    repos, _ = client.ListRepositories()
    cache.Set("repos", repos)
}

3. 条件付きリクエスト(ETag)

GitHubはETagをサポートしています:

type ConditionalClient struct {
    client *Client
    etags  map[string]string
}

func (c *ConditionalClient) GetReposWithETag() ([]*Repository, error) {
    req, _ := c.client.client.NewRequest("GET", "user/repos", nil)
    
    // 前回のETagを設定
    if etag, ok := c.etags["repos"]; ok {
        req.Header.Set("If-None-Match", etag)
    }
    
    var repos []*Repository
    resp, err := c.client.client.Do(c.client.ctx, req, &repos)
    if err != nil {
        return nil, err
    }
    
    // 304 Not Modified = キャッシュが有効
    if resp.StatusCode == http.StatusNotModified {
        return nil, errors.New("use cached data")
    }
    
    // 新しいETagを保存
    c.etags["repos"] = resp.Header.Get("ETag")
    
    return repos, nil
}

実践的なパフォーマンス最適化

ghstatで実際にやったパフォーマンス改善:

Before(遅い)

func GetCommitStats(repos []*Repository) *Stats {
    stats := &Stats{}
    
    // 直列処理:100リポジトリで5分
    for _, repo := range repos {
        commits := getRepoCommits(repo)
        stats.Add(commits)
    }
    
    return stats
}

問題点:

  • 直列処理(1つずつ)
  • ページネーションが非効率
  • キャッシュなし

After(速い)

func GetCommitStats(repos []*Repository) *Stats {
    stats := &Stats{}
    var mu sync.Mutex
    
    // ワーカープール
    workers := 20
    jobs := make(chan *Repository, len(repos))
    var wg sync.WaitGroup
    
    // ワーカー起動
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for repo := range jobs {
                // キャッシュチェック
                if cached := cache.Get(repo.Name); cached != nil {
                    mu.Lock()
                    stats.Add(cached)
                    mu.Unlock()
                    continue
                }
                
                commits := getRepoCommits(repo)
                cache.Set(repo.Name, commits)
                
                mu.Lock()
                stats.Add(commits)
                mu.Unlock()
            }
        }()
    }
    
    // ジョブ投入
    for _, repo := range repos {
        jobs <- repo
    }
    close(jobs)
    
    wg.Wait()
    return stats
}

改善点:

  • 並列処理(20ワーカー)
  • キャッシュ活用
  • 効率的なページネーション

結果: 5分 → 15秒(20倍高速化!)

セキュリティチェックリスト

本番環境に出す前に確認:

✅ トークン管理

  • トークンはハードコードしていない
  • 環境変数または設定ファイルで管理
  • 設定ファイルは.gitignoreに追加
  • 必要最小限のスコープのみ付与
  • トークンに有効期限を設定

✅ エラーハンドリング

  • トークンがエラーメッセージに含まれない
  • APIエラーを適切に処理
  • ネットワークエラーに対応
  • リトライロジックを実装

✅ レート制限

  • レート制限をチェック
  • 制限間近で警告表示
  • リセット時刻を待つ機能
  • キャッシュで呼び出し削減

✅ データ保護

  • ローカルキャッシュの権限設定(0600 or 0644)
  • センシティブなデータは暗号化
  • ログにトークンを出力しない

デバッグテクニック

API呼び出しをデバッグするのは大変。便利なテクニック:

1. リクエスト/レスポンスのログ

type DebugTransport struct {
    Transport http.RoundTripper
}

func (t *DebugTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    // リクエストをログ
    fmt.Printf("→ %s %s\n", req.Method, req.URL.Path)
    
    // ヘッダーをログ
    for k, v := range req.Header {
        if k == "Authorization" {
            fmt.Printf("  %s: ***REDACTED***\n", k)
        } else {
            fmt.Printf("  %s: %v\n", k, v)
        }
    }
    
    // 実際のリクエスト実行
    resp, err := t.Transport.RoundTrip(req)
    
    // レスポンスをログ
    if resp != nil {
        fmt.Printf("← %d %s\n", resp.StatusCode, resp.Status)
        fmt.Printf("  X-RateLimit-Remaining: %s\n", 
            resp.Header.Get("X-RateLimit-Remaining"))
    }
    
    return resp, err
}

// 使用例
func NewDebugClient() *github.Client {
    httpClient := &http.Client{
        Transport: &DebugTransport{
            Transport: http.DefaultTransport,
        },
    }
    
    return github.NewClient(httpClient)
}

2. APIコール数のカウント

type CountingClient struct {
    client   *Client
    callCount map[string]int
    mutex    sync.Mutex
}

func (c *CountingClient) GetRepositories() ([]*Repository, error) {
    c.mutex.Lock()
    c.callCount["ListRepos"]++
    c.mutex.Unlock()
    
    return c.client.ListRepositories()
}

func (c *CountingClient) PrintStats() {
    fmt.Println("API Call Statistics:")
    for endpoint, count := range c.callCount {
        fmt.Printf("  %s: %d calls\n", endpoint, count)
    }
}

3. モックテスト

type MockGitHubClient struct {
    repos []*Repository
    err   error
}

func (m *MockGitHubClient) ListRepositories() ([]*Repository, error) {
    return m.repos, m.err
}

// テスト
func TestGetStats(t *testing.T) {
    mock := &MockGitHubClient{
        repos: []*Repository{
            {Name: "test-repo", Stars: 100},
        },
    }
    
    stats := GetStats(mock)
    if stats.TotalRepos != 1 {
        t.Errorf("Expected 1 repo, got %d", stats.TotalRepos)
    }
}

まとめ

GitHub APIを使いこなすための実践テクニックを紹介しました:

基本:

  • ✅ REST API vs GraphQL API の使い分け
  • ✅ レート制限の正しい扱い方
  • ✅ ページネーションの実装

応用:

  • ✅ エラーハンドリングとリトライ
  • ✅ 並列処理によるパフォーマンス向上
  • ✅ キャッシュ戦略

セキュリティ:

  • ✅ トークン管理のベストプラクティス
  • ✅ セキュリティチェックリスト

デバッグ:

  • ✅ リクエスト/レスポンスのログ
  • ✅ モックテスト

これらのテクニックを使えば、堅牢で高速なGitHub連携ツールが作れます。

次は、これらを活かしてもっと複雑な統計機能チーム向け機能を追加する予定です。

それでは、Happy Coding!


参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?