4年越しの自分のソース全改修はシビれたぜ!!
https://github.com/duri0214/portfolio/pull/188
gptにコミットコメント書かせて、蓄積したコミットコメントをgptでまとめてみた。なかなかいいね
クチコミは絵文字の問題でdbに登録できないので作るのを省略した
https://github.com/duri0214/portfolio/issues/189
Google Cloud Platform のmaps系機能をONにする
※ウェブサイトの制限がONだと403になってしまうところに課題がある(できれば制約をつけたい)。
※Directions API はportfolioの別のアプリケーションで使用している
まぁだいたいこのあたりを使うんじゃないかな?
- places.adrFormatAddress
- places.displayName
- places.formattedAddress
- places.googleMapsUri
- places.id
- places.location
- places.reviews
概要
- 任意のプレイスリスト(例えば水道事業者一覧) をスクレイピング
- 「神奈川県内広域水道企業団」とかそういうワードから緯度経度の配列を取得
- GoogleMapにロケーションアイコンを刺す
というのを当初は目指していたらしいが、どれもやればすぐ実装できるので今回はとにかく的を絞ってDDDリファクタリングに注力した(かなりゴリ押しのソースだったな。。当時必死だったんだろう)。そして、ただ難しいだけの機能もガッツリ削った。
Tips
Django アプリケーションで JSON ファイルを出力して利用しようとした際に、パスの解釈の違いによって404エラーが発生した問題について
※この機能は実装から削ったが、当時起こったトラブルシュートとして残しておく
問題の背景
- Python (Django) での JSON ファイルの出力
Django の view.py ファイルで、JSON データを生成し、それをローカルの静的ファイルとして保存しました。
# view.py
with open('googlemaps/static/googlemaps/js/data.json', 'w') as file:
json.dump(data, file)
このコードは、Django プロジェクトのディレクトリ構造に基づき、googlemaps/static/googlemaps/js/data.json というパスに JSON ファイルを保存しています。
- HTML/JavaScript からの JSON ファイル読み込み
HTML 内の JavaScript では、以下のように jQuery を使用して JSON ファイルを読み込もうとしています。
$.getJSON("/static/googlemaps/js/data.json", function(data) {
console.log(data);
});
JSONが読めない!?Ajax の非同期処理についての理解不足から起きたトラブルとその解決について
問題の背景
- JSON 読み込みの試み
JavaScript の getJSON を使って、サーバーから JSON データを取得しようとしました。
let dataArray = [];
$.getJSON("/static/googlemaps/js/data.json", function(data) {
dataArray = data;
});
console.log(dataArray); // ここで値を確認しようとした
期待した動作
- $.getJSON でデータを取得して dataArray に代入する
- console.log(dataArray) で内容を確認する
実際の動作
- console.log(dataArray) を実行した時点で dataArray は空のまま
- 理由は、Ajax は非同期で動作するため、$.getJSON の処理が完了する前に console.log が実行されてしまうから
非同期処理とは?
非同期処理とは、特定の処理が完了するのを待たずに、次のコードが実行される仕組みを指します。
-
同期処理の場合
一つの処理が完了するまで次の処理は実行されません。ローカルアプリケーションではこの動作が一般的です。
例: ファイルを読み込む → データを加工する → 加工結果を出力する。 -
非同期処理の場合
処理を開始すると、その完了を待たずに次のコードが実行されます。処理が完了したら、指定されたコールバック関数(function(data) の部分)が実行されます。
例: Ajax リクエストを送信 → 他のコードを実行 → レスポンスを受信したらデータを処理
解決策
非同期処理の性質を理解し、適切にコールバックや Promise を使うことで解決できます
- コールバック関数を使用する
データを取得した後に行いたい処理を、コールバック関数内に記述します。
$.getJSON("/static/googlemaps/js/data.json", function(data) {
console.log(data); // データが取得されたタイミングで実行される
});
- Promise(または async/await)を使用する
Promise を使うと非同期処理をより直感的に扱えます。
// Promise を使う例
$.getJSON("/static/googlemaps/js/data.json")
.done(function(data) {
console.log(data); // データが取得されたら実行
});
// async/await を使う例
async function fetchData() {
const response = await fetch("/static/googlemaps/js/data.json");
const data = await response.json();
console.log(data); // データが取得されたら実行
}
fetchData();
非同期処理が慣れない理由
- ローカルアプリケーションでは、同期処理が一般的で、「まだデータがない」という状況がほぼ発生しません
- 一方、Web アプリケーションでは、リクエストやレスポンスに時間がかかることを前提とした非同期処理が一般的です
非同期処理では、「処理の完了を待たずに次に進む」 という動作を理解し、それに合わせてコードを書く必要があります。
Source
domain/repository/googlemaps.py
from django.db.models import QuerySet
from gmarker.domain.valueobject.googlemaps import PlaceVO
from gmarker.models import NearbyPlace
class NearbyPlaceRepository:
"""
近隣場所情報を管理するリポジトリクラスです。
カテゴリーフィールドは以下の値を持ちます:
1 = "Category": 具体的なカテゴリで検索され、登録される場所
2 = "Pin Select": Googleマップ上でピンを選び、登録した場所
3 = "Database Insert": データベースから直接挿入される情報
9 = "Default Location": デフォルトの場所(マップを初期表示したときの中心)
ここで、"Category"と"Pin Select"は主にユーザーが画面から登録し、"Database Insert"は主にメンテナンス時に使用されます。
"""
CATEGORY_SEARCH = 1
PIN_SELECT = 2
DATABASE_INSERT = 3
DEFAULT_LOCATION = 9
@staticmethod
def find_by_category(category: int) -> QuerySet[NearbyPlace]:
return NearbyPlace.objects.filter(category=category)
@staticmethod
def delete_by_category(category: int) -> bool:
query = NearbyPlace.objects.filter(category=category)
if query.count() > 0:
query.delete()
return True
return False
@staticmethod
def bulk_create(objects: list[NearbyPlace]):
NearbyPlace.objects.bulk_create(objects)
@classmethod
def get_default_location(cls) -> NearbyPlace:
return NearbyPlace.objects.get(category=cls.DEFAULT_LOCATION)
@staticmethod
def handle_search_code(category: int, search_types: str, places: list[PlaceVO]):
NearbyPlaceRepository.delete_by_category(category)
if places:
new_places = []
for place in places:
new_places.append(
NearbyPlace(
category=category,
search_types=search_types,
place_id=place.place_id,
name=place.name,
location=place.location.to_str(),
rating=place.rating,
)
)
NearbyPlaceRepository.bulk_create(new_places)
domain/service/googlemaps.py
import requests
from gmarker.domain.valueobject.googlemaps import PlaceVO
from lib.geo.valueobject.coords import GoogleMapCoords
class GoogleMapsService:
def __init__(self, api_key: str):
self.api_key = api_key
self.base_url = "https://places.googleapis.com/v1/places"
def nearby_search(
self,
center: GoogleMapCoords,
search_types: list[str],
radius: int,
fields: list[str],
) -> list[PlaceVO]:
"""
Google Maps Places APIのNearby Search (New)を使用して施設を検索します。
Args:
center: 検索中心の座標。
search_types: 検索する場所のタイプ。
radius: 検索半径(メートル)。
fields: 取得するフィールドのリスト。
Returns:
検索結果の施設リスト。
Raises:
requests.HTTPError: HTTPエラーが発生した場合。
ValueError: fieldsが空の場合。
"""
if not fields:
raise ValueError("fieldsパラメータは必須です")
url = f"{self.base_url}:searchNearby"
request_body = {
"locationRestriction": {
"circle": {
"center": {
"latitude": center.latitude,
"longitude": center.longitude,
},
"radius": radius,
}
},
"includedTypes": search_types,
"languageCode": "ja",
}
headers = {
"Content-Type": "application/json",
"X-Goog-Api-Key": self.api_key,
"X-Goog-FieldMask": ",".join(fields),
}
try:
response = requests.post(url, headers=headers, json=request_body)
response.raise_for_status()
data = response.json()
places_data = data.get("places", [])
places: list[PlaceVO] = []
for place_data in places_data:
latlng = place_data.get("location")
if latlng:
latlng = GoogleMapCoords(
latitude=latlng.get("latitude"),
longitude=latlng.get("longitude"),
)
places.append(
PlaceVO(
place_id=place_data.get("id"),
location=latlng,
name=place_data.get("displayName", {}).get("text"),
rating=place_data.get("rating"),
reviews=[],
)
)
return places
except requests.HTTPError as e:
print(f"An HTTP error occurred: {e}")
return []
except (KeyError, TypeError) as e:
print(f"A data parsing error occurred: {e}")
return []
except Exception as e:
print(f"An unexpected error occurred: {e}")
return []
domain/valueobject/googlemaps.py
from dataclasses import dataclass
from lib.geo.valueobject.coords import GoogleMapCoords
@dataclass
class PlaceVO:
"""
このデータクラスは、Google Places APIから取得した場所の詳細情報を表します。
プロパティ:
place_id: 場所のGoogle Places IDを表す文字列。
location: 場所の座標を表すGoogleMapCoordsのインスタンス。
name: 場所の名前を表す文字列。
rating: 場所の評価を表す浮動小数点数。
reviews: 将来的に場所のレビュー情報を含む可能性があるリスト。
"""
place_id: str
location: GoogleMapCoords
name: str
rating: float
reviews: list
fixtures/nearbyPlace.json
[
{
"model": "gmarker.nearbyPlace",
"fields": {
"category": 9,
"name": "渋谷駅",
"location": "35.6598003,139.7023894",
"created_at": "2023-02-12T09:00:00+09:00"
}
},
{
"model": "gmarker.nearbyPlace",
"fields": {
"category": 3,
"name": "忠犬ハチ公像",
"location": "35.6590439,139.7005917",
"created_at": "2023-02-12T09:00:00+09:00"
}
},
{
"model": "gmarker.nearbyPlace",
"fields": {
"category": 3,
"name": "明治神宮",
"location": "35.676236,139.6993411",
"created_at": "2023-02-12T09:00:00+09:00"
}
}
]
models.py
from django.db import models
class NearbyPlace(models.Model):
"""
NearbyPlaceモデルはGoogle Placesから取得した近隣の場所に関する情報を保存します。
ユーザーが画面から登録するのは "Category"と"Pin Select"です。 "Database Insert"は主にメンテナンスのときに使います。
"""
category = (
models.IntegerField()
) # TODO: 現在時点では自位置の9の特定に必要だがバラしてフィールドにしたら?
search_types = models.CharField(null=True, blank=True, max_length=100)
place_id = models.CharField(null=True, blank=True, max_length=200)
name = models.CharField(max_length=200)
location = models.CharField(max_length=100)
rating = models.FloatField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True, null=True)
static/gmarker/js/map.js
let infoWindow;
class MarkerData {
constructor(data) {
this.lat = data.location.lat;
this.lng = data.location.lng;
this.title = data.name || "マーカー";
this.placeId = data.place_id;
this.rating = data.rating;
}
toHtml() {
return `
<div>
<p>${this.title}</p>
<p>Place ID: ${this.placeId}<br>lat,lng: ${this.lat},${this.lng}<br>rating: ${this.rating}</p>
</div>
`;
}
}
function initMap(jsonData) {
try {
if (!jsonData) {
console.error("jsonData is not defined.");
return;
}
const {Map, Marker} = google.maps;
const options = {
zoom: 14,
center: new google.maps.LatLng(jsonData.center.lat, jsonData.center.lng),
mapTypeId: google.maps.MapTypeId.ROADMAP,
mapTypeControl: false,
keyboardShortcuts: false,
streetViewControl: false,
fullscreenControl: false,
scrollwheel: true
};
map = new Map(document.getElementById("map_canvas1"), options);
createMarkersFromData(jsonData.places, Marker, map);
} catch (e) {
console.error("Google Maps API の初期化エラー:", e);
alert("地図の読み込みに失敗しました。インターネット接続を確認してください。");
}
}
function createMarkersFromData(places, Marker, map) {
if (!map || !places || !Marker) {
console.error("Missing map, places, or Marker");
return;
}
places.map(place => {
const markerData = new MarkerData(place);
const marker = new Marker({
position: new google.maps.LatLng(markerData.lat, markerData.lng),
map: map,
animation: google.maps.Animation.DROP,
title: markerData.title
});
marker.addListener('click', () => {
showInfoWindow(marker, markerData.toHtml());
});
return {marker, data: markerData};
});
}
function showInfoWindow(marker, content) {
if (infoWindow) {
infoWindow.close();
}
infoWindow = new google.maps.InfoWindow({content});
infoWindow.open(map, marker);
}
templates/gmarker/base.html
<!-- googlemap api -->
のあたりを参考にして
{% load static %}
<!DOCTYPE html>
<html lang="ja">
<head>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-43097095-9"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'UA-43097095-9');
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>gmarker</title>
<!-- bootstrap and css -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css"
integrity="sha384-GJzZqFGwb1QTTN6wy59ffF1BuGJpLSa9DkKMp0DgiMDm4iYMj70gZWKYbI706tWS" crossorigin="anonymous">
<link rel="stylesheet" href="{% static 'gmarker/css/index.css' %}">
<!-- favicon -->
<link rel="icon" href="{% static 'gmarker/c_g.ico' %}">
<!-- for ajax -->
<script>let myUrl = {"base": "{% url 'mrk:index' %}"};</script>
<!-- googlemap api -->
<script src="{% static 'gmarker/js/map.js' %}" defer></script>
<script>
let map;
window.myMapInit = () => {
document.addEventListener('DOMContentLoaded', () => {
const mapData = JSON.parse('{{ map_data|escapejs }}');
map = initMap(mapData);
});
};
</script>
<script src="https://maps.googleapis.com/maps/api/js?key={{ google_maps_api_key }}&callback=myMapInit&libraries=places&v=weekly"
async defer onerror="console.error('Google Maps API failed to load.');"></script>
<script src="https://cdn.jsdelivr.net/npm/js-cookie@beta/dist/js.cookie.min.js"></script>
</head>
<body>
<h1></h1>
<header>
<nav class="navbar fixed-top navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="{% url 'home:index' %}">Henojiya</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<select class="custom-select d-flex align-items-center" onChange="location.href=value;">
<option value="{% url 'home:index' %}">HOME</option>
<option value="{% url 'vnm:index' %}">VIETNAM</option>
<option value="{% url 'mrk:index' %}" selected>GMARKER</option>
<option value="{% url 'shp:index' %}">SHOPPING</option>
<option value="{% url 'war:index' %}">WAREHOUSE</option>
<option value="{% url 'txo:index' %}">TAXONOMY</option>
<option value="{% url 'soil:home' %}">SOIL ANALYSIS</option>
<option value="{% url 'sec:index' %}">SECURITIES REPORT</option>
<option value="{% url 'hsp:index' %}">HOSPITAL</option>
<option value="{% url 'llm:index' %}">LLM_CHAT</option>
</select>
</li>
</ul>
<form class="form-inline my-2 my-lg-0">
<input class="form-control mr-sm-2" type="search" placeholder="Search" aria-label="Search">
<button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
</form>
</div>
</nav>
</header>
<div id="main">
{% block content %}{% endblock %}
</div>
<footer>
<p>© 2019 henojiya. / <a href="https://github.com/duri0214" target="_blank">github portfolio</a></p>
</footer>
<!-- Optional JavaScript -->
<!-- jQuery first, then Popper.js, then Bootstrap JS -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.6/umd/popper.min.js"
integrity="sha384-wHAiFfRlMFy6i5SRaxvfOCifBUQy1xHdJ/yoi7FRNXMRBu5WHdZYu1hA6ZOblgut"
crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js"
integrity="sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k"
crossorigin="anonymous"></script>
</body>
</html>
templates/gmarker/index.html
{% extends "gmarker/base.html" %}
{% load static %}
{% block content %}
<div class="jumbotron">
<h1 class="display-4">Hello, googlemap!</h1>
<div id="map_canvas1" style="width: 1000px; height: 500px"></div>
</div>
<h5>カテゴリーサーチ</h5>
<form class="ml-4" action="{% url 'mrk:nearby_search' 1 %}" method="POST">
{% csrf_token %}
<input type="submit" class="btn-flat-border" value="レストラン"/> My拠点を中心に半径1500mのレストランのピンが表示される
</form>
{% endblock %}
tests/test_views.py
from django.test import TestCase
from django.urls import reverse
from gmarker.models import NearbyPlace
class TestView(TestCase):
@classmethod
def setUpTestData(cls):
NearbyPlace.objects.create(
category=9,
name="渋谷駅",
location="35.6598003,139.7023894",
created_at="2023-02-12 00:00:00",
)
NearbyPlace.objects.create(
category=3,
name="忠犬ハチ公像",
location="35.6590439,139.7005917",
created_at="2023-02-12 00:00:00",
)
NearbyPlace.objects.create(
category=3,
name="明治神宮",
location="35.676236,139.6993411",
created_at="2023-02-12 00:00:00",
)
def test_get_top_page_200(self):
response = self.client.get(reverse("mrk:index"))
self.assertEqual(200, response.status_code)
urls.py
from django.urls import path
from gmarker.views import IndexView
app_name = "mrk"
urlpatterns = [
path("", IndexView.as_view(), name="index"),
path("search/<int:search_code>", IndexView.as_view(), name="nearby_search"),
]
views.py
import json
import os
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.views.generic import TemplateView
from gmarker.domain.repository.googlemaps import NearbyPlaceRepository
from gmarker.domain.service.googlemaps import GoogleMapsService
from lib.geo.valueobject.coords import GoogleMapCoords
class IndexView(TemplateView):
template_name = "gmarker/index.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
search_code = self.kwargs.get("search_code", 9)
places = NearbyPlaceRepository.find_by_category(search_code)
place_data = []
for place in places:
try:
lat, lng = map(float, place.location.split(","))
place_data.append(
{
"location": {
"lat": lat,
"lng": lng,
},
"name": place.name,
"place_id": place.place_id,
"rating": place.rating,
}
)
except ValueError:
print(
f"Invalid location format: {place.location} for place {place.name}"
)
continue
map_center = NearbyPlaceRepository.get_default_location()
center_lat, center_lng = map(float, map_center.location.split(","))
map_data = {
"center": {
"lat": center_lat,
"lng": center_lng,
},
"places": place_data,
}
context["map_data"] = json.dumps(map_data, ensure_ascii=False)
context["google_maps_api_key"] = os.getenv("GOOGLE_MAPS_API_KEY")
return context
@staticmethod
def post(request, search_code: int):
if search_code == NearbyPlaceRepository.CATEGORY_SEARCH:
map_center = NearbyPlaceRepository.get_default_location()
center_lat, center_lng = map(float, map_center.location.split(","))
search_types = ["restaurant"]
service = GoogleMapsService(os.getenv("GOOGLE_MAPS_API_KEY"))
places = service.nearby_search(
center=GoogleMapCoords(center_lat, center_lng),
search_types=search_types,
radius=1500,
fields=[
"places.id",
"places.location",
"places.displayName.text",
"places.rating",
],
)
NearbyPlaceRepository.handle_search_code(
category=NearbyPlaceRepository.CATEGORY_SEARCH,
search_types=",".join(search_types),
places=places,
)
return redirect(
reverse_lazy("mrk:nearby_search", kwargs={"search_code": search_code})
)