はじめに
今回は、Spring Bootで「住所を入力すると現在の天気を表示するWebアプリ」を作成しました。
前回までは都道府県を選択して天気を表示していましたが、今回は一段進めて、住所入力から緯度・経度を取得し、その位置情報をもとに天気情報を取得する流れにしました。
使用したAPIは以下の2つです。
HeartRails Geo API
→ 入力された住所から緯度・経度を取得する
Open-Meteo API
→ 緯度・経度から現在の天気情報を取得する
また、GETとPOSTの違いを復習するため、以下のような画面遷移にしました。
名前入力画面
↓ POST送信
住所入力画面
↓ GET送信
天気結果画面
作成したもの
今回作成した画面は以下の3つです。
1. 名前入力画面
2. 住所入力画面
3. 天気結果画面
名前入力画面では、ユーザー名を入力します。
名前を入力してください
[ 田中 ]
[ 次へ ]
住所入力画面では、天気を調べたい住所を入力します。
住所から天気を検索
田中 さん、住所を入力してください。
[ 東京都新宿区西新宿 ]
[ 天気を検索 ]
天気結果画面では、入力した住所、取得できた住所、緯度・経度、天気情報を表示します。
田中 さんの天気検索結果
入力した住所:東京都新宿区西新宿
取得できた住所:東京都新宿区西新宿
緯度:...
経度:...
天気:曇り
気温:...
風速:...
環境
今回の環境は以下です。
OS:Windows11
IDE:Eclipse 2026
Java:Java SE 21
Spring Boot:4.1.0
ビルドツール:Maven
テンプレートエンジン:Thymeleaf
JSON処理:Jackson
プロジェクト構成
新しく作成したプロジェクト名は以下です。
AddressWeatherApp
パッケージ構成は以下のようにしました。
src/main/java/com/example/addressweather
├─ AddressWeatherAppApplication.java
├─ controller
│ └─ WeatherController.java
├─ form
│ ├─ NameForm.java
│ └─ AddressSearchForm.java
├─ service
│ ├─ GeoService.java
│ └─ WeatherService.java
└─ dto
├─ LocationInfo.java
└─ WeatherInfo.java
src/main/resources/templates
├─ name-form.html
├─ address-form.html
└─ weather.html
今回は少し実務寄りに、役割ごとにパッケージを分けました。
controller
→ 画面遷移を担当する
form
→ 画面から送られてきた入力値を受け取る
service
→ 外部API呼び出しや処理を担当する
dto
→ 画面表示やService間で使うデータを持つ
GETとPOSTの使い分け
今回のアプリでは、GETとPOSTを以下のように使い分けました。
名前入力
→ POST送信
住所検索
→ GET送信
名前入力画面では、以下のようにPOST送信にしています。
ファイル:templates/name-form.html
<form th:action="@{/address}" th:object="${nameForm}" method="post">
POST送信にすると、入力した名前はURLに表示されません。
一方、住所入力画面ではGET送信にしています。
ファイル:templates/address-form.html
<form th:action="@{/weather}" th:object="${addressSearchForm}" method="get">
GET送信にすると、入力した住所がURLに表示されます。
/weather?address=東京都新宿区西新宿
住所検索は「検索処理」なので、検索条件がURLに出るGET送信にしました。
Formクラス
画面から送られてきた入力値を受け取るために、Formクラスを作成しました。
ファイル:form/NameForm.java
package com.example.addressweather.form;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;
// ユーザー名入力フォーム
@Getter
@Setter
public class NameForm {
@NotBlank(message = "名前を入力してください。")
private String userName;
}
名前が未入力の場合は、@NotBlank によってエラーになります。
ファイル:form/AddressSearchForm.java
package com.example.addressweather.form;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;
// 住所検索フォーム
@Getter
@Setter
public class AddressSearchForm {
@NotBlank(message = "住所を入力してください。")
private String address;
}
住所も未入力の場合はエラーになるようにしています。
DTOクラス
外部APIから取得した値や、画面に表示する値をまとめるためにDTOクラスを作成しました。
ファイル:dto/LocationInfo.java
package com.example.addressweather.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
// 住所検索APIから取得した位置情報
@Getter
@AllArgsConstructor
public class LocationInfo {
private String prefecture;
private String city;
private String town;
private String postal;
private double latitude;
private double longitude;
public String getFullAddress() {
return prefecture + city + town;
}
}
LocationInfo は、HeartRails Geo APIから取得した住所情報を持つクラスです。
都道府県
市区町村
町域
郵便番号
緯度
経度
を保持します。
ファイル:dto/WeatherInfo.java
package com.example.addressweather.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
// 天気結果画面に表示する情報
@Getter
@AllArgsConstructor
public class WeatherInfo {
private String userName;
private String searchedAddress;
private String resolvedAddress;
private double latitude;
private double longitude;
private String time;
private double temperature;
private double windspeed;
private int winddirection;
private int weathercode;
private String weatherName;
}
WeatherInfo は、天気結果画面に表示する情報をまとめるクラスです。
入力した住所だけでなく、APIから取得できた住所や緯度・経度も表示できるようにしました。
Controller
画面遷移を担当するControllerを作成しました。
ファイル:controller/WeatherController.java
package com.example.addressweather.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import com.example.addressweather.dto.LocationInfo;
import com.example.addressweather.dto.WeatherInfo;
import com.example.addressweather.form.AddressSearchForm;
import com.example.addressweather.form.NameForm;
import com.example.addressweather.service.GeoService;
import com.example.addressweather.service.WeatherService;
import jakarta.servlet.http.HttpSession;
// 画面遷移を担当するController
@Controller
public class WeatherController {
private final GeoService geoService;
private final WeatherService weatherService;
public WeatherController(GeoService geoService, WeatherService weatherService) {
this.geoService = geoService;
this.weatherService = weatherService;
}
@GetMapping("/")
public String showNameForm(Model model) {
model.addAttribute("nameForm", new NameForm());
return "name-form";
}
@PostMapping("/address")
public String receiveName(
@Validated NameForm nameForm,
BindingResult bindingResult,
HttpSession session) {
if (bindingResult.hasErrors()) {
return "name-form";
}
session.setAttribute("userName", nameForm.getUserName());
return "redirect:/address";
}
@GetMapping("/address")
public String showAddressForm(HttpSession session, Model model) {
String userName = (String) session.getAttribute("userName");
if (userName == null) {
return "redirect:/";
}
model.addAttribute("userName", userName);
model.addAttribute("addressSearchForm", new AddressSearchForm());
return "address-form";
}
@GetMapping("/weather")
public String showWeather(
@Validated AddressSearchForm addressSearchForm,
BindingResult bindingResult,
HttpSession session,
Model model) {
String userName = (String) session.getAttribute("userName");
if (userName == null) {
return "redirect:/";
}
model.addAttribute("userName", userName);
if (bindingResult.hasErrors()) {
return "address-form";
}
try {
String address = addressSearchForm.getAddress();
LocationInfo locationInfo = geoService.findLocationByAddress(address);
WeatherInfo weatherInfo = weatherService.getWeather(
userName,
address,
locationInfo
);
model.addAttribute("weatherInfo", weatherInfo);
return "weather";
} catch (Exception e) {
model.addAttribute("apiErrorMessage", "住所または天気情報の取得に失敗しました。入力内容を確認してください。");
return "address-form";
}
}
}
POST後にredirectする
名前入力後の処理では、以下のようにしています。
ファイル:controller/WeatherController.java
session.setAttribute("userName", nameForm.getUserName());
return "redirect:/address";
最初は、POST処理の中で直接 address-form.html を表示していました。
しかし、その場合、住所入力画面に遷移した直後に名前が表示されませんでした。
そこで、POST処理ではSessionに名前を保存したあと、redirect:/address でGETの住所入力画面へ遷移させる形にしました。
POST /address
↓
Sessionに名前を保存
↓
redirect:/address
↓
GET /address
↓
Sessionから名前を取得
↓
画面に名前を表示
この流れは、POST-Redirect-GETという考え方に近いものです。
GeoService
住所から緯度・経度を取得する処理は、GeoService に分けました。
ファイル:service/GeoService.java
package com.example.addressweather.service;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import com.example.addressweather.dto.LocationInfo;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
// HeartRails Geo APIを使って、住所から緯度・経度を取得するService
@Service
public class GeoService {
public LocationInfo findLocationByAddress(String address) throws Exception {
RestClient restClient = RestClient.create();
String result = restClient.get()
.uri(uriBuilder -> uriBuilder
.scheme("https")
.host("geoapi.heartrails.com")
.path("/api/json")
.queryParam("method", "suggest")
.queryParam("matching", "like")
.queryParam("keyword", address)
.build())
.retrieve()
.body(String.class);
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(result);
JsonNode response = root.get("response");
if (response == null) {
throw new Exception("住所検索APIのレスポンスが不正です。");
}
JsonNode error = response.get("error");
if (error != null) {
throw new Exception(error.asString());
}
JsonNode locations = response.get("location");
if (locations == null) {
throw new Exception("住所が見つかりませんでした。");
}
JsonNode location;
if (locations.isArray()) {
if (locations.size() == 0) {
throw new Exception("住所が見つかりませんでした。");
}
location = locations.get(0);
} else {
location = locations;
}
String prefecture = getText(location, "prefecture");
String city = getText(location, "city");
String town = getText(location, "town");
String postal = getText(location, "postal");
double longitude = Double.parseDouble(getText(location, "x"));
double latitude = Double.parseDouble(getText(location, "y"));
return new LocationInfo(
prefecture,
city,
town,
postal,
latitude,
longitude
);
}
private String getText(JsonNode node, String fieldName) {
if (node.get(fieldName) == null) {
return "";
}
return node.get(fieldName).asString();
}
}
URLエンコードで詰まったところ
最初は、住所を以下のように手動でURLエンコードしていました。
ファイル:service/GeoService.java
String encodedAddress = URLEncoder.encode(address, StandardCharsets.UTF_8);
しかし、この状態だとAPI側にうまく住所が渡らず、以下のようなエラーが返ってきました。
City '%E6%96%B0%E5%AE%BF' does not exist.
これは、API側に「新宿」ではなく、エンコード後の文字列がそのまま渡ってしまっていたためです。
そのため、手動で URLEncoder を使うのをやめて、uriBuilder.queryParam() を使う形に変更しました。
ファイル:service/GeoService.java
.queryParam("keyword", address)
これにより、Spring側にURLの組み立てを任せる形になり、正常に住所検索できるようになりました。
WeatherService
緯度・経度から天気情報を取得する処理は、WeatherService に分けました。
ファイル:service/WeatherService.java
package com.example.addressweather.service;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import com.example.addressweather.dto.LocationInfo;
import com.example.addressweather.dto.WeatherInfo;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
// Open-Meteo APIを使って天気情報を取得するService
@Service
public class WeatherService {
public WeatherInfo getWeather(
String userName,
String searchedAddress,
LocationInfo locationInfo) throws Exception {
try {
RestClient restClient = RestClient.create();
String url = "https://api.open-meteo.com/v1/forecast"
+ "?latitude=" + locationInfo.getLatitude()
+ "&longitude=" + locationInfo.getLongitude()
+ "¤t_weather=true"
+ "&timezone=Asia/Tokyo";
String result = restClient.get()
.uri(url)
.retrieve()
.body(String.class);
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(result);
JsonNode currentWeather = root.get("current_weather");
String time = currentWeather.get("time").asString();
double temperature = currentWeather.get("temperature").asDouble();
double windspeed = currentWeather.get("windspeed").asDouble();
int winddirection = currentWeather.get("winddirection").asInt();
int weathercode = currentWeather.get("weathercode").asInt();
String weatherName = convertWeatherCode(weathercode);
return new WeatherInfo(
userName,
searchedAddress,
locationInfo.getFullAddress(),
locationInfo.getLatitude(),
locationInfo.getLongitude(),
time,
temperature,
windspeed,
winddirection,
weathercode,
weatherName
);
} catch (Exception e) {
throw new Exception("天気情報の取得に失敗しました。", e);
}
}
private String convertWeatherCode(int weathercode) {
switch (weathercode) {
case 0:
return "快晴";
case 1:
return "主に晴れ";
case 2:
return "一部曇り";
case 3:
return "曇り";
case 45:
case 48:
return "霧";
case 51:
case 53:
case 55:
return "霧雨";
case 61:
case 63:
case 65:
return "雨";
case 71:
case 73:
case 75:
return "雪";
case 80:
case 81:
case 82:
return "にわか雨";
case 95:
return "雷雨";
default:
return "不明";
}
}
}
画面側の実装
名前入力画面
ファイル:templates/name-form.html
<form th:action="@{/address}" th:object="${nameForm}" method="post">
<input type="text" th:field="*{userName}" placeholder="例:田中">
<p class="error"
th:if="${#fields.hasErrors('userName')}"
th:errors="*{userName}">
</p>
<button type="submit">次へ</button>
</form>
名前入力画面ではPOST送信を使っています。
住所入力画面
ファイル:templates/address-form.html
<p>
<span th:text="${userName}"></span> さん、住所を入力してください。
</p>
<form th:action="@{/weather}" th:object="${addressSearchForm}" method="get">
<input type="text" th:field="*{address}" placeholder="住所を入力してください">
<p class="error"
th:if="${#fields.hasErrors('address')}"
th:errors="*{address}">
</p>
<p class="error"
th:if="${apiErrorMessage}"
th:text="${apiErrorMessage}">
</p>
<button type="submit">天気を検索</button>
</form>
住所入力画面ではGET送信を使っています。
検索後は、URLに住所が表示されます。
/weather?address=東京都新宿区西新宿
天気結果画面
ファイル:templates/weather.html
<h1>
<span th:text="${weatherInfo.userName}"></span> さんの天気検索結果
</h1>
<div class="item">
<span class="label">入力した住所:</span>
<span th:text="${weatherInfo.searchedAddress}"></span>
</div>
<div class="item">
<span class="label">取得できた住所:</span>
<span th:text="${weatherInfo.resolvedAddress}"></span>
</div>
<div class="item">
<span class="label">緯度:</span>
<span th:text="${weatherInfo.latitude}"></span>
</div>
<div class="item">
<span class="label">経度:</span>
<span th:text="${weatherInfo.longitude}"></span>
</div>
<div class="item">
<span class="label">天気:</span>
<span th:text="${weatherInfo.weatherName}"></span>
</div>
<div class="item">
<span class="label">気温:</span>
<span th:text="${weatherInfo.temperature}"></span> ℃
</div>
結果画面では、入力住所、取得住所、緯度・経度、天気情報を表示します。
動作確認
アプリを起動し、以下にアクセスします。
http://localhost:8080/
まず名前を入力します。
田中
POST送信なので、この時点で名前はURLに表示されません。
次に住所を入力します。
東京都新宿区西新宿
住所検索はGET送信なので、URLに検索条件が表示されます。
/weather?address=東京都新宿区西新宿
天気結果画面で、住所・緯度・経度・天気情報が表示されれば成功です。
今回学んだこと
今回学んだことは以下です。
新規Spring Bootプロジェクトの作成
役割ごとのパッケージ分割
Formクラスによる入力値の受け取り
DTOクラスによるデータ保持
Sessionを使った名前の保持
POST送信とGET送信の使い分け
HeartRails Geo APIによる住所検索
Open-Meteo APIによる天気取得
外部APIのJSON解析
Serviceクラスへの処理分離
特に重要だと感じたのは、GETとPOSTの使い分けです。
POST
→ URLに出したくない情報を送る場合に使う
GET
→ 検索条件としてURLに表示してもよい情報を送る場合に使う
また、外部APIを使う場合は、APIから返ってくるJSONの形や、URLの作り方に注意が必要だと分かりました。
まとめ
今回は、HeartRails Geo APIとOpen-Meteo APIを組み合わせて、住所から現在の天気を検索するWebアプリを作成しました。
前回までの都道府県選択型の天気アプリから発展し、今回は住所入力、緯度・経度取得、天気取得という流れを実装しました。
また、名前入力ではPOST送信、住所検索ではGET送信を使い分けることで、HTTPメソッドの違いも確認できました。
処理をController、Form、Service、DTOに分けることで、以前よりも役割が整理された構成にできたと思います。
今後は、住所候補を複数表示して選択できるようにしたり、CSSを外部ファイル化したりして、さらに実用的な形に近づけていきたいです。