LoginSignup
0
0

Goでwebアプリケーションを作成してみよう! 第4回 ~バックエンドの実装~

Last updated at Posted at 2024-05-15

この記事では5回に分けてwebアプリケーションの作り方を説明していく
第1回
 webアプリケーションの構成、仕組み
第2回
 サーバー立ち上げ
第3回
フロントエンドの実装
第4回
  バックエンドの実装(この記事)
第5回
メニュー画像の読み取り

実際に作ったアプリケーション

やること

JSON 形式で送られてきた現在地と希望金額の情報から、近場のWi-Fiのある場所で、かつ希望金額以下のメニューがあるところのデータを送信する。
構成としては3つに分けられ、
1つ目が受け取ってきたデータを処理する部分、そしてこちら側で処理したデータを返却する部分
2つ目が近場のWi-Fiがある場所を探す部分
3つ目がその場所のメニューで希望金額以下のものがあるのかを判定する部分だ

それぞれについてコードを交えて説明していく
ファイル関係は以下のようになっており、それぞれの処理部分を分けてある

ファイル構成
WifiRader/
├── frontend/
|   ├── app.css               # スタイルシート定義(第2回)
|   ├── app.js                # クライアントサイド(フロントエンド)のロジック  画面の動きをここに書く(第3回)
|   └── server.js             # Node.jsのサーバーサイド(バックエンド)スクリプト(第2回)
└──backend/
   ├── looking-for.go         #近場のWi-Fiがある場所を探す部分
   ├── main.go                                #データの処理部分
   └── scraping.go                        #希望金額以下のメニューがあるかどうかの判別をする部分(第5回)

まずはデータの処理部分から書いていく

データ処理

今回は拡張性の確保やセキュリティの面からマルチプレクサを使用して、異なるエンドポイントでの処理を可能にするようにした。(実際は一つしか使っていないが、今後に何か機能を追加するときのことを考えた)

main.go
func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/submit-location", submitLocationHandler)

	c := cors.New(cors.Options{
		AllowedOrigins:   []string{"http://localhost:3000"},
		AllowedMethods:   []string{"POST"},
		AllowedHeaders:   []string{"Content-Type"},
		AllowCredentials: true,
	})

	handler := c.Handler(mux)
    http.ListenAndServe(":8080", handler)
	fmt.Println("Server is running on http://localhost:8080")
}

まず一番上のマルチプレクサについて説明する

マルチプレクサ
http.NewServeMux()

は新しいHTTPリクエストマルチプレクサを作成します。これはURLルートとそれに対応するハンドラを管理するためのものである。要するに、どのURLパターンがどの関数によって処理されるかを定義するためのルーターの役割を果たす。
それを

mux.HandleFunc("/submit-location", submitLocationHandler)

ここで設定している。今回だと、設定したローカルホストの下の /submit-location つまり http://localhost:8080/submit-location にリクエストが送られたら、submitLocationHandler 関数で処理されるということ。

そして以下の部分で CORS の設定もしている。

CORSの設定
c := cors.New(cors.Options{
		AllowedOrigins:   []string{"http://localhost:3000"},
		AllowedMethods:   []string{"POST"},
		AllowedHeaders:   []string{"Content-Type"},
		AllowCredentials: true,
	})

	handler := c.Handler(mux)

ここでは CORS を設定することで、異なるオリジン (今回は異なるポート) からのリクエストを許可し、今回は POST メソッドと Content-Type ヘッダの使用を許可している。

そして最後に HTTP サーバーを指定したポート (今回は8080) で起動し、外部からのリクエストを待つ

これでサーバーの起動は終わったので、次に、実際のデータ処理の部分を見ていこうと思う。

データ処理

ここでは submit-location にリクエストが送られてきたときの処理を書いていく。最初にデータ構造を決めてから実際にコードを書いていく

今回は受け取るデータを以下のような構造体で定義していく

受け取るデータ
type LocationData struct {
	Pos struct {
		Latitude  float64 `json:"latitude"`
		Longitude float64 `json:"longitude"`
	} `json:"pos"`
	DesiredAmount int `json:"desiredAmount"`
}

見てわかるように、受け取る値を一つの構造体にまとめた。こうする事で、他の関数への受け渡しや、値が増えた時でも対応しやすくなっている。一番右側の `` で囲まれた部分はタグと呼ばれており、これはメタデータをフィールドに結びつけるときなどに使用される。
例えば、

Latitude float64 json:"latitude"

の場合、json:"latitude"はencoding/jsonパッケージに対する指示であり、このフィールドがJSONへエンコード(データを JSON 形式にする) する際、またはJSONからデコード (JSON 形式を基の形式に直す) される際にlatitude`というキー名で対応することを示している。つまり、この構造体をJSONに変換する際には以下のような形式になる

JSON形式へエンコード
{
    "latitude": 12.345678
}

ではいよいよコードを書いていく。コードを記述して、それについて説明していく。

submitLocationHandler
func submitLocationHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")

	if r.Method != "POST" {
		http.Error(w, `{"error":"Only POST method is allowed"}`, http.StatusMethodNotAllowed)
		return
	}

	// レスポンスボディの読み込み
	body, err := io.ReadAll(r.Body)
	if err != nil {
		log.Printf("Error reading body: %v", err)
		http.Error(w, `{"error":"Can't read body"}`, http.StatusBadRequest)
		return
	}

	//デコード
	if err := json.Unmarshal(body, &data); err != nil {
		log.Printf("Error decoding JSON: %v", err)
		http.Error(w, fmt.Sprintf(`{"error":"%v"}`, err), http.StatusBadRequest)
		return
	}

	log.Printf("Received location: %v, %v and desired amount: %d", data.Pos.Latitude, data.Pos.Longitude, data.DesiredAmount)

	location = fmt.Sprintf("%f,%f", data.Pos.Latitude, data.Pos.Longitude)
	places, err := searchPlaces(apiKey, location, radius, keyword)
	if err != nil {
		errMsg := fmt.Sprintf("Failed to search places: %v", err)
		log.Println(errMsg)
		http.Error(w, fmt.Sprintf(`{"error":"%v"}`, errMsg), http.StatusInternalServerError)
		return
	}

	//構造体配列で情報を扱う
	var response []PlaceInfo
	for _, place := range places.Results {
		url, err := fetchPlaceDetails(apiKey, place.PlaceID)
		if err != nil {
			log.Printf("Failed to fetch details for place %s: %v", place.Name, err)
			continue
		}
		response = append(response, PlaceInfo{Name: place.Name, URL: url, Latitude: place.Geometry.Location.Lat, Longitude: place.Geometry.Location.Lng})
		//fmt.Printf("Place: %s, URL: %s\n", place.Name, url)
	}

	resp := checkmenu(data.DesiredAmount, response)

	if err := json.NewEncoder(w).Encode(resp); err != nil {
		log.Printf("Error encoding JSON: %v", err)
		http.Error(w, `{"error":"Error encoding JSON"}`, http.StatusInternalServerError)
	}
}

この関数の引数の http.ResponseWriter と *http.Request についてまず説明する

w http.RescponseWriter はHTTPレスポンスを書き込むためのインターフェース。このオブジェクトを使用して、クライアントに送信するHTTPレスポンスを制御する
r *http.Request はクライアントから受け取ったHTTPリクエストの詳細を含むオブジェクト。ここに、リクエストの method や pos などの情報が含まれている

次の w.Header().Set("Content=Type","application/json") では、返却する型が決められており、レスポンスのContent-Typeヘッダをapplication/jsonに設定している。これで、レスポンスボディがJSON形式であることをクライアントに伝える

その下の if 文では、 method が POST か確認しており、もし違う場合はエラー処理が行われる。このときのhttp.Error (net/http パッケージ)は以下のような引数の意味合いになっている。

http.Error
func Error(w http.ResponseWriter, error string, code int)

一つ目の引数は、これはクライアントにHTTPレスポンスを送信するためのインターフェース。 http.ResponseWriter を使用してレスポンスのヘッダーやボディを設定し、最終的にレスポンスをクライアントに送信する
二つ目の引数は、クライアントに返されるエラーメッセージ
三つ目の引数は、これはHTTPステータスコードで、レスポンスとしてクライアントに返されます。例えば、404(Not Found)、500(Internal Server Error)、403(Forbidden)などがあり、これによって、クライアントに状況を伝える。今回の http.StatusMethodNotAllowed と int 型の405は等価

次にクライアントから送られた HTTP リクエストからボディの内容を読み込む。

リクエストボディの読み込み
body, err := io.ReadAll(r.Body)
	if err != nil {
		log.Printf("Error reading body: %v", err)
		http.Error(w, `{"error":"Can't read body"}`, http.StatusBadRequest)//400と等価
		return
	}

一番上の io.ReadAll(r.Body) では io パッケージの ReadAll 関数を使用して HTTP リクエストのボディを読み込んでいる。io.ReadAll 関数とは io.Reader インターフェースを実装している任意のデータストリームからすべてのデータを読み出し、それをバイトスライス([]byte)として返す。つまり簡単に言うと、引数に渡したデータにアクセスし(ファイルやネットワーク接続などの様々なソースから来る)、それをバイトスライスとして返却している。バイトスライスとは、テキストデータ(人間が読みやすいもの)やバイナリデータ(機械が読みやすい2進数形式のデータ)などで返される、というものだ

また、その下の if 文でエラー処理を行なっている。

次に、デコード処理を行なっている

デコード
if err := json.Unmarshal(body, &data); err != nil {
   	log.Printf("Error decoding JSON: %v", err)
   	http.Error(w, fmt.Sprintf(`{"error":"%v"}`, err), http.StatusBadRequest)
   	return
   }

   log.Printf("Received location: %v, %v and desired amount: %d", data.Pos.Latitude, data.Pos.Longitude, data.DesiredAmount)

ここでのデコードとは JSON 形式で渡されたデータを

data
type LocationData struct {
   Pos struct {
   	Latitude  float64 `json:"latitude"`
   	Longitude float64 `json:"longitude"`
   } `json:"pos"`
   DesiredAmount int `json:"desiredAmount"`
}

この型の data に直す、ということだ。つまり、data には

data
data{
    Pos{
        Latitude //渡された現在地の緯度
        Longitude //渡された現在地の経度
    }
    DesiredAmount  //ユーザーが入力した希望金額
}

が入っている。これを Go で扱う。デコードは Unmarshal 関数が行ってくれ、バイトスライスとJSONデータをデコードした結果を格納するためのGoの変数(またはデータ構造)へのポインタを渡せば良い。そうするとエラー型で値が返却され、うまく成功した場合には nil が返却される

もしエラーだった時の処理は今までと同様である
もし正しくできていれば、画面にそれらの内容が表示される

これで必要な情報は全て揃ったので、これから現在地から近いWi-Fiスポットの検索と、それらの店のメニューから情報を読み解く作業を行なっていく

まず、searchPlaces という関数を作り、その関数で、現在地から近いWi-Fiスポットの検索をしていく。

looking-for.go 関数を作成していく

このファイルでは現在地からWi-Fiスポットの検索をする searchPlaces という関数と、その中の店の情報を一つずつ見ていって、URLを取得する fetchDetails という関数の二つに分かれている。一つにまとめられそうと思われるかもしれないが、それぞれの機能ごとに分けた方が可読性が高いと判断したため、二つに分けた

main 関数でsearchPlaces関数とfetchDetatils関数を呼び出したところを書いてから、実際のそれぞれのコードを書いていく。

main
type PlaceInfo struct {
	Name      string  `json:"name"`
	URL       string  `json:"url"`
	Latitude  float64 `json:"Latitude"`
	Longitude float64 `json:"Longitude"`
}

// 自動で読み込み
func init() {
	apiKey = os.Getenv("GOOGLE_MAPS_API_KEY")
	radius = "1500"
	keyword = "Wi-Fi study"
}

func submitLocationHandler(w http.ResponseWriter, r *http.Request) {
    //今まで記載したコード
    location = fmt.Sprintf("%f,%f", data.Pos.Latitude, data.Pos.Longitude)
	places, err := searchPlaces(apiKey, location, radius, keyword)
	if err != nil {
		errMsg := fmt.Sprintf("Failed to search places: %v", err)
		log.Println(errMsg)
		http.Error(w, fmt.Sprintf(`{"error":"%v"}`, errMsg), http.StatusInternalServerError)
		return
	}
 
	//構造体配列で情報を扱う
	var response []PlaceInfo
	for _, place := range places.Results {
		url, err := fetchPlaceDetails(apiKey, place.PlaceID)
		if err != nil {
			log.Printf("Failed to fetch details for place %s: %v", place.Name, err)
			continue
		}
		response = append(response, PlaceInfo{Name: place.Name, URL: url, Latitude: place.Geometry.Location.Lat, Longitude: place.Geometry.Location.Lng})
		//fmt.Printf("Place: %s, URL: %s\n", place.Name, url)  動作確認のために付けているが、本番になったら要らない
	}
 }

places というのは、searchPlaces 関数で返される

返却型
type PlaceSearchResponse struct {
   Results []struct {
   	Name     string `json:"name"`
   	PlaceID  string `json:"place_id"`
   	Geometry struct {
   		Location struct {
   			Lat float64 `json:"lat"`
   			Lng float64 `json:"lng"`
   		} `json:"location"`
   	} `json:"geometry"`
   } `json:"results"`
   Status string `json:"status"`
}

このような構造体配列を格納するために使用する。なぜこのような構造体 ( Getometry の中に Location を入れたのか ) にしたのかというと、Google Places API からの標準的なレスポンスは以下のような階層構造を持っており、

レスポンス
{
  "results": [
    {
      "geometry": {
        "location": {
          "lat": 34.0522,
          "lng": -118.2437
        }
      },
      "name": "Some Place",
      "place_id": "ChIJ7aVxnOTHwoARxKIntFtakKo"
    }
  ],
  "status": "OK"
}

Google Places API のレスポンスデータ構造を正確にミラーリングすることにある。APIが提供するデータの階層をそのままGoの構造体で表現することで、JSONデータのパースが容易になり、エラーの可能性が減少する。
この関数で返却された places という変数の中にある住所の情報から fetchPlacesDetailsという関数でその店の URL を取得してきて、それを response という PlaceInfo 型の配列に格納している。その後、その店の URL から画像を検索したりする関数を作成する。searchPlaces 関数と fetchPlacesDetails という関数は統合することも不可能ではないが、各関数は一つの機能に集中するべき、という単一責任の原則や再利用性と拡張性、パフォーマンス面のことを考えると分けるのが妥当である。
では、searchPlaces関数を見ていく。

searchPlaces
func searchPlaces(apiKey, location, radius, keyword string) (*PlaceSearchResponse, error) {
	// URLエンコードを使用してキーワードをエンコード
	encodedKeyword := url.QueryEscape(keyword)

	requestURL := fmt.Sprintf("https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=%s&radius=%s&keyword=%s&type=cafe&key=%s", location, radius, encodedKeyword, apiKey)

	resp, err := http.Get(requestURL)
	if err != nil {
		log.Printf("Failed to send request to Google Places API: %v", err)
		return nil, err
	}

	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	// レスポンスボディのデコード
	var result PlaceSearchResponse
	if err := json.Unmarshal(body, &result); err != nil {
		return nil, err
	}

	return &result, nil
}

PlaceSearchResponse 型の配列を返却する関数。一番最初の行のURLエンコードを使用してキーワードをエンコードとは、URL に直接書き込むことが許されていない文字(空白や特殊文字など)を安全に伝送するために、これらの文字をパーセント記号(%)に続いて2桁の16進数で表されるASCII値に変換している。これにより、安全に requestURL を構築できる。
この requestURL は、Google Places APIの「Nearby Search」エンドポイントへのリクエストURLを作成している。
キーワードや現在地、タイプなどを絞った requestURL を使用して、Google Places API に HTTP リクエストを行い、返却されたものをrespに入れる。この時の resp には上に記載したレスポンスのような形で返ってくる。

defer resp.Body.Close()

resp.Body は、HTTPリクエストのレスポンスとして返されるデータストリーム。このボディは、データを読み取った後、開いたままのネットワーク接続のリソースを解放するために明示的に閉じる必要があ理、Close() メソッドを呼び出すことで、関連するリソースが適切に解放され、後続のリクエストや他の操作に影響を与えないようにする。

しかし、今回のようにエラーケースやうまくいったときの返却ポイントが複数ある場合は、それぞれの返却地点に対してこの Close の処理を行わなければならない。しかしこれは複雑になりがちなので、一つにまとめよう、というので defer 処理というのがある。
この処理をすることで、どのリターン地点でも、そのリターンの直前に Close を行ってからリターンする、ということができる。これによって、複数書くべき Close の処理を一箇所にまとめることができる。

body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

これで resp.Body(HTTPレスポンスの本文)からすべてのデータを読み取り、読み取ったデータをバイトスライス (配列) にして返却する。それを body という変数に代入している。(JSON形式)

それを result という PlaceSearchResponse 型(構造体配列)の変数に代入して返却する。
これにて SerachPlaces関数は終了だ。

そして submitLocationHandler 関数に戻ってもらうと、

main.go
	places, err := searchPlaces(apiKey, location, radius, keyword)
	if err != nil {
		errMsg := fmt.Sprintf("Failed to search places: %v", err)
		log.Println(errMsg)
		http.Error(w, fmt.Sprintf(`{"error":"%v"}`, errMsg), http.StatusInternalServerError)
		return
	}

	//構造体配列で情報を扱う
	var response []PlaceInfo
	for _, place := range places.Results {
		url, err := fetchPlaceDetails(apiKey, place.PlaceID)
		if err != nil {
			log.Printf("Failed to fetch details for place %s: %v", place.Name, err)
			continue
		}
		response = append(response, PlaceInfo{Name: place.Name, URL: url, Latitude: place.Geometry.Location.Lat, Longitude: place.Geometry.Location.Lng})
		//fmt.Printf("Place: %s, URL: %s\n", place.Name, url)
	}

このようにあり、とりあえず places に情報が入ったので、それの中から fetchDetails 関数を使用して URL を取得して、

//構造体配列で情報を扱う
	var response []PlaceInfo

ここに入れていきたいと思う

fetchDetails 関数

ここでも searchPlaces 関数と似たような動作をしているので簡単に説明していこうと思う

fetchDetails関数
func fetchPlaceDetails(apiKey, placeID string) (string, error) {
	detailURL := fmt.Sprintf("https://maps.googleapis.com/maps/api/place/details/json?placeid=%s&key=%s", placeID, apiKey)
	resp, err := http.Get(detailURL)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}

	var details struct {
		Result struct {
			Website string `json:"website"`
		} `json:"result"`
		Status string `json:"status"`
	}
	if err := json.Unmarshal(body, &details); err != nil {
		return "", err
	}

	if details.Status != "OK" {
		return "", fmt.Errorf("failed to get place details: %s", details.Status)
	}

	return details.Result.Website, nil
}

今回は detailURL に fmt.Sprintf を使用して、Google Places APIの "Place Details" エンドポイントに対するリクエストURLを組み立てる。その際、URLには placeid(取得したい場所のID)と apiKey(APIを使用するための認証キー)をクエリパラメータとして含める。

そして、searchPlaces 関数と同様の操作で、resp に HTTP GET リクエストの結果を返却する。

また、defer resp.Body.Close() によって確実にレスポンスボディを閉じてから返却する。
そして、resp.Body の中身を body に読み取らせる。これにより、

  • エラーハンドリングの分離(データ読み込みに関するエラーをここだけにまとめられる)
  • JSONデコードのシンプル化(ストリームから直接デコードする場合、デコーダはデータの一部しか見ることができず、必要な情報がストリームの後半部分に来る場合、効率が悪くなる)
  • データの再利用性(APIから得たデータを body に保存しておくことで、後で同じデータを異なる目的で再利用することが容易になる)
    点などで便利になる。

その後 JSON 形式から Go の details 構造体の形に直して、そのデータの URL を返却する

これにて、現在地から近いカフェのURLを取得することができた。

最後に

これで現在地から近いカフェの場所を取得できたので、次の回でメニュー画面を読み込んで希望金額以下のメニューがあるかどうかを調べるコードを書いていく。

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