🏂

俺は!この遅いAPIをGoroutineで早くしてみせる!!

2023/01/20に公開

概要

まず、簡単に処理の概要をChatGptくんに説明してもらいました。

HTTPリクエストに対して、Open Graph Protocol(OGP)の情報を返すためのハンドラー関数です。ハンドラー関数は、URLクエリパラメーターに指定されたURLからOGP情報を取得し、その結果をJSON形式でクライアントに返します。特に、TwitterのURLがクエリパラメーターに指定された場合は、Twitterのプロフィール画像のURLも追加して返します。また、取得したOGP情報はCache-Controlヘッダーによって1年間キャッシュされるように設定されています。

クエリパラメータにhttps://test.com/?<OGP欲しいURL>&<OGP欲しいURL>&<OGP欲しいURL>
的な感じで、セットしてそれらをFor文で回してOGPライブラリを使用して取得するというもの。

前提条件

  • VercelにServerless関数としてデプロイしている
  • VercelのHobbyプランでは10秒経過するとタイムアウトしてしまう
  • なんとTwitterリンクが2つ対象に含まれると
    2回に1回の頻度で10秒を超過して504エラーになってしまう・・・
  • 著者は初めてのGoなので、玄人の方いましたらぜひアドバイスください

先に結果発表

なんと、Goroutineを使用したところ約半分の時間で処理が完了しました。

(Vercelのサーバーレス関数のCPUコア数は2のようなので、2つのGoroutineで並行処理しているはずで、当然といえば当然かもしれない)

ちなみに上記はTwitterリンク2つでしたが、調子に乗って24個でAPI叩いて見ました。

まさかの10秒切る

意図としてはCPUのコア数を上限にGoroutineを起動して並行処理したつもりだったので、24個は捌けないかなと思ってました。
ちょ、ちょっとよくわからないですけど、速い分には嬉しいのでOK

Goroutineを使用しない場合のコード(Before)

package handler

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"
	"strings"

	"github.com/dyatlov/go-opengraph/opengraph"
)

func Handler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Cache-Control", "s-maxage=31536000")

	jsonStrings := []string{}
	v := r.URL.Query()

	if v == nil {
		return
	}
	for _, url := range v {
		target := url[0]

		if target == "" {
			fmt.Println("No URL")
			continue
		}

		client := &http.Client{}
		req, _ := http.NewRequest("GET", target, nil)

		// twitter用にUser-Agentをbotに設定
		req.Header.Add("User-Agent", "bot")

		resp, _ := client.Do(req)
		defer resp.Body.Close()
		body, _ := ioutil.ReadAll(resp.Body)

		// ライブラリのOGPインスタンス
		og := opengraph.NewOpenGraph()
		// HTMLの解析
		err := og.ProcessHTML(strings.NewReader(string(body)))

		if err != nil {
			fmt.Println(err)
			continue
		}

		if og.SiteName == "" {
			jsonStrings = append(jsonStrings, og.String())
			continue
		}

		if og.SiteName == "Twitter" {
			if og.URL == "" {
				jsonStrings = append(jsonStrings, og.String())
				continue
			}

			//TwitterのURLの場合、プロフィール画像もJSONに追加する
			parts := strings.Split(og.URL, "/")

			//
			if len(parts) < 4 || parts[3] == "" {
				jsonStrings = append(jsonStrings, og.String())
				continue
			}

			userName := parts[3]
			profileUrl := "https://twitter.com/" + userName

			req, _ := http.NewRequest("GET", profileUrl, nil)

			// twitter用にUser-Agentをbotに設定
			req.Header.Add("User-Agent", "bot")

			resp, _ := client.Do(req)
			defer resp.Body.Close()
			body, _ := ioutil.ReadAll(resp.Body)

			// ライブラリのOGPインスタンス
			og2 := opengraph.NewOpenGraph()
			// HTMLの解析
			err := og2.ProcessHTML(strings.NewReader(string(body)))
			if err != nil {
				fmt.Println(err)
				continue
			}

			//twitterのプロフィールにアクセスできなければ終了
			if og2.Type != "profile" {
				jsonStrings = append(jsonStrings, og.String())
				continue
			}

			var data map[string]interface{}
			json.Unmarshal([]byte(og.String()), &data)
			data["ProfileImage"] = og2.Images[0].URL
			addedProfileImageOg, _ := json.Marshal(data)

			jsonStrings = append(jsonStrings, string(addedProfileImageOg))
		} else {
			jsonStrings = append(jsonStrings, og.String())
		}
	}

	//文字列で格納されているOGP情報を構造体に戻して、mapにして再格納する
	var jsonData []map[string]interface{}
	for _, jsonString := range jsonStrings {
		var data map[string]interface{}
		json.Unmarshal([]byte(jsonString), &data)
		jsonData = append(jsonData, data)
	}

	// mapをまとめてエンコードして出力
	jsonBytes, _ := json.Marshal(jsonData)
	fmt.Fprintf(w, "%s", jsonBytes)
}

Goroutineを使用した場合のコード

package handler

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"
	"runtime"
	"strings"
	"time"

	"github.com/dyatlov/go-opengraph/opengraph"
)

func worker(id int, jobs <-chan []string, results chan<- string) {

	url := <-jobs
	target := url[0]

	if target == "" {
		fmt.Println("No URL")
		return
	}

	client := &http.Client{}
	req, _ := http.NewRequest("GET", target, nil)

	// twitter用にUser-Agentをbotに設定
	req.Header.Add("User-Agent", "bot")

	resp, _ := client.Do(req)
	defer resp.Body.Close()
	body, _ := ioutil.ReadAll(resp.Body)

	// ライブラリのOGPインスタンス
	og := opengraph.NewOpenGraph()
	// HTMLの解析
	err := og.ProcessHTML(strings.NewReader(string(body)))

	if err != nil {
		fmt.Println(err)
		return
	}

	if og.SiteName == "" {
		results <- og.String()
		return
	}

	if og.SiteName == "Twitter" {
		if og.URL == "" {
			results <- og.String()
			return
		}

		//TwitterのURLの場合、プロフィール画像もJSONに追加する
		parts := strings.Split(og.URL, "/")

		//
		if len(parts) < 4 || parts[3] == "" {
			results <- og.String()
			return
		}

		userName := parts[3]
		profileUrl := "https://twitter.com/" + userName

		req, _ := http.NewRequest("GET", profileUrl, nil)

		// twitter用にUser-Agentをbotに設定
		req.Header.Add("User-Agent", "bot")

		resp, _ := client.Do(req)
		defer resp.Body.Close()
		body, _ := ioutil.ReadAll(resp.Body)

		// ライブラリのOGPインスタンス
		og2 := opengraph.NewOpenGraph()
		// HTMLの解析
		err := og2.ProcessHTML(strings.NewReader(string(body)))
		if err != nil {
			fmt.Println(err)
			return
		}

		//twitterのプロフィールにアクセスできなければ終了
		if og2.Type != "profile" {
			results <- og.String()
			return
		}

		var data map[string]interface{}
		json.Unmarshal([]byte(og.String()), &data)
		data["ProfileImage"] = og2.Images[0].URL
		addedProfileImageOg, _ := json.Marshal(data)

		results <- string(addedProfileImageOg)
	} else {
		results <- og.String()
	}

}

func Handler(w http.ResponseWriter, r *http.Request) {
	start := time.Now()
	w.Header().Set("Cache-Control", "s-maxage=31536000")
	
	cpus := runtime.NumCPU()
	fmt.Printf("CPUのコア数: %v\n", cpus)
	v := r.URL.Query()

	if v == nil {
		return
	}

	var numJobs = len(v)

	jobs := make(chan []string, cpus)
	results := make(chan string, cpus)

	// パラメータで与えられたURL数のワーカーを開始
	// ただし最初はまだジョブがないためブロックされる
	for w := 1; w <= numJobs; w++ {
		go worker(w, jobs, results)
	}

	// 次にジョブとして実際のURL値を送信し(jobChのバッファリングはCPUコア数まで)、それがすべてであることを
	// 伝えるためにチャネルを `close` します。
	for _, url := range v {
		jobs <- url
	}

	close(jobs)

	var jsonData []map[string]interface{}

	//resultsChから文字列で格納されているOGP情報を取り出し、構造体に戻して、mapにして再格納する
	for i := 1; i <= numJobs; i++ {
		var data map[string]interface{}
		json.Unmarshal([]byte(<-results), &data)
		jsonData = append(jsonData, data)
	}



	// mapをまとめてエンコードして出力
	jsonBytes, _ := json.Marshal(jsonData)

	end := time.Now()
	fmt.Printf("Process Time: %f sec\n", (end.Sub(start)).Seconds())
	
	fmt.Fprintf(w, "%s", jsonBytes)
}

つまづいたところ

  • チャンネルから値を取り出す時の挙動が慣れなかった。
    具体的に言うと、resultsチャンネルに結果を入れた瞬間に、resultsチャンネルから取り出す処理が実行されるので、思うように実行結果(今回の場合はリンクごとのOGP情報)を束ねてJson出力できなかった。
    例えば、こう書けば実行結果をまとめて処理できると思ったが、resultsチャンネルに値が入った瞬間rangeのfor文が動くので、1つGoroutineの結果で出力されたり、そもそも最後まで処理が走らなかった。
    ので、↓のように書き換えたところうまく通りました。(ちなみに参考にしてたgobyexampleの例)は最初からそう書いてあった

	for _,result range results {
		var data map[string]interface{}
		json.Unmarshal([]byte(<-result), &data)
		jsonData = append(jsonData, data)
	}	
	
	↓↓
	
	for i := 1; i <= numJobs; i++ {
		var data map[string]interface{}
		json.Unmarshal([]byte(<-results), &data)
		jsonData = append(jsonData, data)
	}


自分にとってはキューのようなイメージだと思った。
逆に言えばチャンネルに値が来るまでは出す時の処理は行われないので、この辺りをお手軽にやってくれるので、並行処理もお手軽に実装できるんだなあと実感した。

参考記事
https://zenn.dev/someone7140/articles/c12ecce613674b
https://qiita.com/JunkiHiroi/items/f03d4297e11ce5db172e
https://www.wakuwakubank.com/posts/790-go-goroutine-basic/

Discussion