はじめに
現在、方言を話すおしゃべり猫型ロボット「ミーア」を開発中。
今までは、アプリ側のみでOpenWeatherMapのAPIを使用して、指定された緯度と経度を使用して天気情報を取得しアプリ画面に表示していたが、下記のようにバックエンド経由に変更する。
- ユーザーは初期会員登録時に、都道府県と市区町村を入力する。
- アプリ側で、ユーザーが入力した都道府県と市区町村から緯度と経度を取得し、緯度と経度をクエリパラメーターとして添えて、APIエンドポイントを作成し、バックエンド側に投げる。
- APIエンドポイントをバックエンドで受け取ると、OpenWeatherMapのAPIを使用して緯度と軽度の情報から、天気情報(最高気温と天気)を返す。
- 返ってきた天気情報をアプリのホーム画面に表示する。
バックエンド回収
server.goファイルに新しいエンドポイントを追加
OpenWeatherMap API documentはこちら。
https://openweathermap.org/current
今回は、緯度と経度をクエリパラメーターにして、天気情報を取得したい。
echo
フレームワークでは、クエリパラメータをルートに直接追加する必要はないとのこと。クエリパラメータは動的ではないので、ルート定義には含めない。代わりに、ハンドラ関数内でc.QueryParam("lat")
やc.QueryParam("lon")
のようにしてクエリパラメータを取得する。
package clocky_be
import (
"github.com/labstack/echo/v4"
)
type Server struct {
e *echo.Echo
c *Config
}
func NewServer(config *Config) *Server {
e := echo.New()
// 天気情報取得のためのハンドラを作成
wh := NewWeatherHandler(config)
// アプリ用のエンドポイント
appGroup := e.Group("/app")
appGroup.Use(FirebaseAuth(config))
// 天気情報取得のエンドポイントを追加
appGroup.GET("/weather", wh.HandleGetWeather)
return &Server{e: e, c: config}
}
このようにして、エンドポイントを定義すると、/app/weather?lat=35.6895&lon=139.6917
のようなリクエストでもHandleGetWeather
ハンドラが呼び出され、ハンドラ内でlat
とlon
のクエリパラメータを取得できる。
天気情報を取得する関数を定義
weather_handler.go
を作成し、天気情報を取得するためのハンドラ関数を定義。
OpenWeatherMap APIの、JSON format response例はこちら。
今回はこの中の、天気情報(weather[“description”])と、最高気温(main[“temp_max”])を利用する。
{
"coord": {
"lon": 10.99,
"lat": 44.34
},
"weather": [
{
"id": 501,
"main": "Rain",
"description": "moderate rain",
"icon": "10d"
}
],
"base": "stations",
"main": {
"temp": 298.48,
"feels_like": 298.74,
"temp_min": 297.56,
"temp_max": 300.05,
"pressure": 1015,
"humidity": 64,
"sea_level": 1015,
"grnd_level": 933
},
"visibility": 10000,
"wind": {
"speed": 0.62,
"deg": 349,
"gust": 1.18
},
"rain": {
"1h": 3.16
},
"clouds": {
"all": 100
},
"dt": 1661870592,
"sys": {
"type": 2,
"id": 2075663,
"country": "IT",
"sunrise": 1661834187,
"sunset": 1661882248
},
"timezone": 7200,
"id": 3163858,
"name": "Zocca",
"cod": 200
}
package clocky_be
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"github.com/labstack/echo/v4"
)
const OpenWeatherMapURL = "http://api.openweathermap.org/data/2.5/weather"
type WeatherHandler struct {
Config *Config
}
type WeatherResponse struct {
Description string `json:"description"`
TempMax float64 `json:"temp_max"`
}
func NewWeatherHandler(config *Config) *WeatherHandler {
return &WeatherHandler{
Config: config,
}
}
func (wh *WeatherHandler) HandleGetWeather(c echo.Context) error {
// Firebase認証の確認
uid := c.Get("uid")
if uid == nil {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "User not authenticated",
})
}
lat := c.QueryParam("lat")
lng := c.QueryParam("lng")
apiKey := wh.Config.OpenWeatherMapAPIKey
lang := c.QueryParam("lang")
if lang == "" {
lang = "en"
}
resp, err := http.Get(fmt.Sprintf("%s?lat=%s&lng=%s&appid=%s&units=metric&lang=%s", OpenWeatherMapURL, lat, lng, apiKey, lang))
if err != nil {
return c.JSON(http.StatusInternalServerError, err.Error())
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return c.JSON(http.StatusInternalServerError, err.Error())
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return c.JSON(http.StatusInternalServerError, err.Error())
}
weatherData := data["weather"].([]interface{})[0].(map[string]interface{})
description := weatherData["description"].(string)
mainData := data["main"].(map[string]interface{})
tempMax := mainData["temp_max"].(float64)
return c.JSON(http.StatusOK, WeatherResponse{
Description: description,
TempMax: tempMax,
})
}package clocky_be
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"github.com/labstack/echo/v4"
)
const OpenWeatherMapURL = "http://api.openweathermap.org/data/2.5/weather"
type WeatherHandler struct{}
type WeatherResponse struct {
Description string `json:"description"`
TempMax float64 `json:"temp_max"`
}
func NewWeatherHandler() *WeatherHandler {
return &WeatherHandler{}
}
func (wh *WeatherHandler) HandleGetWeather(c echo.Context) error {
lat := c.QueryParam("lat")
lon := c.QueryParam("lon")
apiKey := os.Getenv("OPENWEATHERMAP_API_KEY") // 環境変数からAPIキーを取得
resp, err := http.Get(fmt.Sprintf("%s?lat=%s&lon=%s&appid=%s&units=metric", OpenWeatherMapURL, lat, lon, apiKey))
if err != nil {
return c.JSON(http.StatusInternalServerError, err.Error())
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return c.JSON(http.StatusInternalServerError, err.Error())
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return c.JSON(http.StatusInternalServerError, err.Error())
}
weatherData := data["weather"].([]interface{})[0].(map[string]interface{})
description := weatherData["description"].(string)
mainData := data["main"].(map[string]interface{})
tempMax := mainData["temp_max"].(float64)
return c.JSON(http.StatusOK, WeatherResponse{
Description: description,
TempMax: tempMax,
})
}
まず、OpenWeatherMap
APIからのJSONレスポンスに含まれるdescription
はweather
キーの配列の最初の要素のdescription
プロパティから取得される。取得したdescription
は、Go言語のWeatherResponse
構造体のDescription
フィールドに一時保存される。
次に、c.JSON
関数を使ってHTTPレスポンスとしてWeatherResponse
構造体をJSONにエンコードして返す時、構造体のフィールドタグに定義されたJSONキー名が使用される。例えば、Description
フィールドはjson:"description"
としてタグ付けされているため、JSONオブジェクトでは"description"
キーとしてエンコードされる。
したがって、最終的にクライアントに返されるJSONレスポンスは以下のようになる。
{
"description": "晴れ時々雲",
"temp_max": 30.0
}
ここで、description
、temp_max
はそれぞれWeatherResponse
構造体のフィールドタグで指定された名前。このようにして、Go言語の構造体とJSONキー名との間で名前のマッピングを行う。
アプリ回収
ApiClientクラスにgetWeatherメソッドを作成
今までは、アプリ側にOpenWeatherMAPのAPIキーを埋め込んでAPIにアクセスしていたが、今回の改修に伴い、ApiClientクラスにバックエンド経由で情報を取得するgetWeatherメソッドを作成
api_client.dart
class ApiClient {
final String apiUrl;
ApiClient(this.apiUrl);
Future<Map<String, dynamic>> getWeather(String lat, String lon, String lang) async {
final headers = await apiHeaders();
final url = Uri.parse('$apiUrl/app/weather?lat=$lat&lon=$lon&lang=$lang');
final response = await http.get(
url,
headers: headers
);
if (response.statusCode != 200) {
throw Exception('Failed to fetch weather');
}
return json.decode(response.body);
}
}
weather_service.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/services.dart';
import '../api/api_client.dart';
import '../api/api_client_provider.dart';
import 'dart:convert';
final weatherServiceProvider = Provider<WeatherService>((ref) {
final apiClient = ref.watch(apiClientProvider);
return WeatherService(apiClient: apiClient);
});
class WeatherService {
final ApiClient apiClient;
// WeatherServiceクラスのコンストラクタ
// ApiClientクラスのインスタンスを引数として受け取り、それをapiClientというメンバ変数(プロパティ)に代入
WeatherService({required this.apiClient});
Future<String?> getWeatherInfo(String pref, String city) async {
String jsonText = await rootBundle.loadString('assets/japanese_locations.json');
List<dynamic> locations = jsonDecode(jsonText);
var geoData = locations.firstWhere(
(loc) => loc['pref'] == pref && loc['city'] == city,
orElse: () => null,
);
if (geoData == null) {
print('Location not found in locations file');
return null;
}
// 緯度と経度を出力
print('Latitude: ${geoData['lat']}, Longitude: ${geoData['lng']}');
String _lat = geoData['lat'];
String _lon = geoData['lng'];
String lang = "ja"; // 日本語をデフォルトとして設定
final weatherData = await apiClient.getWeather(_lat, _lon, lang);
if (weatherData != null) {
var mainWeather = weatherData['description'];
var tempMax = weatherData['temp_max'];
return '今日の天気は、$mainWeather。最高気温は${tempMax.round()}度だよ。';
} else {
print('天気情報を読み取れませんでした');
return null;
}
}
}
weather_service.dartファイル内で行っている、状態管理と依存性注入に関して
依存性注入(Dependency Injection)
あるクラスが依存するオブジェクト(依存性)を外部から注入する手法。この手法によって、コードの再利用性、テスタビリティ、メンテナンス性が向上。
例えば、WeatherService
クラスはAPIにアクセスするためにApiClient
クラスの機能が必要だが、それを内部で直接生成(new ApiClient()
など)するのではなく、外部からコンストラクタ経由で受け取る。これにより、WeatherService
はApiClient
の具体的な実装に依存しなくなり、テストや再利用が容易になる。
下記のように、WeatherService
のインスタンス生成時にApiClient
のインスタンスを注入。
final weatherService = WeatherService(apiClient: someApiClientInstance);
Riverpodによる状態管理
Provider
を使用してWeatherService
のインスタンスを生成し、そのインスタンスをアプリケーションの他の部分で利用できるようにする。
https://riverpod.dev/ja/docs/concepts/reading
final weatherServiceProvider = Provider<WeatherService>((ref) {
// 'ref'オブジェクトを通じてapiClientプロバイダを利用
final apiClient = ref.watch(apiClientProvider);
return WeatherService(apiClient: apiClient);
});
HomeTabウィジェット
WeatherService
のProvider
を使用して、非同期に天気情報を取得し、それをUIに反映する。
// 既存コード
import 'package:clocky_app/services/weather_service.dart'; // 天気情報サービスをインポート
// 既存コード
class HomeTab extends ConsumerStatefulWidget {
const HomeTab();
@override
_HomeTabState createState() => _HomeTabState();
}
class _HomeTabState extends ConsumerState<HomeTab> {
// 既存コード
String? _weatherInfo; // 天気情報を格納する変数
final WeatherService _weatherService = WeatherService(); // WeatherService インスタンスを生成
// 既存コード
@override
void didChangeDependencies() {
super.didChangeDependencies();
_getWeatherInfo(); // 依存関係が変わるたびに天気情報を取得
}
// 天気情報を非同期で取得するメソッド
void _getWeatherInfo() async {
final user = ref.read(userProvider); // ユーザー情報を取得
final prefecture = user?.prefecture; // 都道府県情報を取得
final city = user?.city; // 市区町村情報を取得
// ユーザーと地域情報がnullでなければ天気情報を取得
if (user != null && prefecture != null && city != null) {
var weatherInfo = await _weatherService.getWeatherInfo(prefecture, city); // 天気情報をAPIから取得
weatherInfo ??= "Unable to fetch weather information"; // 天気情報がnullならデフォルトメッセージを設定
setState(() {
_weatherInfo = weatherInfo; // 状態を更新してUIに反映
});
}
}
@override
Widget build(BuildContext context) {
final user = ref.read(userProvider); // ユーザー情報を取得
// 既存コード
return DebugContainer(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
// 既存コード
if (_weatherInfo != null) Text(_weatherInfo!), // _weatherInfoがnullでなければテキストとして表示
// 既存コード
],
),
),
),
);
}
}
動作確認
サーバーダウン問題の解消
PCに実機を繋いでflutter runでビルドしたところ、サーバーダウンエラーが表示された。
モバイルデバイスとAPIサーバーが同じローカルネットワーク内にあれば、ローカルIPを使用してAPIから情報を取得できるので、アプリ側で API_URL=http://localhost:8080
と設定して、iOSシュミレーターの場合には、これで起動できたが、実機からアクセスした場合はできなかった。理由をgptに聞いたところ
iOS シミュレーターは開発しているPC自体で動作しているので、localhost
またはループバックアドレス 127.0.0.1
を指定すると、シミュレーターは実行中の API サーバーにアクセスできます。そのため、シミュレーターから http://localhost:8080
にアクセスすると、同じマシン上で動作している Web サーバーにリクエストが行くわけです。
しかし、物理的なデバイス(スマートフォンなど)を使用する場合、そのデバイスは独立したネットワークエンティティであるため、localhost
では開発マシン上のサーバーにアクセスできません。その場合、開発PCのネットワーク内でのIPアドレスを指定する必要があります。
とのこと。なるほど。
開発PCのネットワーク内でのIPアドレスを確認する必要があるので、ターミナルで
$ ifconfig | grep 'inet '
とコマンドを打ったところ
inet 127.0.0.1 netmask 0xff000000
inet 192.168.10.101 netmask 0xffffff00 broadcast 192.168.10.255
inet 169.254.200.163 netmask 0xffff0000 broadcast 169.254.255.255
と返信が来た。このうち、ループバックアドレスの127.0.0.1
以外(今回だと、192.168.10.101)が開発PCのネットワークIPアドレスなのでAPI_URL=http://192.168.10.101:8080
と設定したところ、無事実機でも起動できた。
完成!
無事、天気情報がホーム画面に表示されています!
dockerでlocal apiのログを見ると、apiにクエリ渡してresponse返ってきていることも確認できた。URIのエンドポイントも下記のように、緯度と経度がクエリパラメーターとして付与されている。
"uri":"/app/weather?lat=35.71055556&lon=139.8016667&lang=ja”
コメント