方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【Go × Echo】バックエンドで天気予報情報を取得し、Flutterアプリに表示する

この記事は約23分で読めます。

はじめに

現在、方言を話すおしゃべり猫型ロボット「ミーア」を開発中。

https://mia-cat.com

今までは、アプリ側のみでOpenWeatherMapのAPIを使用して、指定された緯度と経度を使用して天気情報を取得しアプリ画面に表示していたが、下記のようにバックエンド経由に変更する。

  • ユーザーは初期会員登録時に、都道府県と市区町村を入力する。
  • アプリ側で、ユーザーが入力した都道府県と市区町村から緯度と経度を取得し、緯度と経度をクエリパラメーターとして添えて、APIエンドポイントを作成し、バックエンド側に投げる。
  • APIエンドポイントをバックエンドで受け取ると、OpenWeatherMapのAPIを使用して緯度と軽度の情報から、天気情報(最高気温と天気)を返す。
  • 返ってきた天気情報をアプリのホーム画面に表示する。

バックエンド回収

server.goファイルに新しいエンドポイントを追加

OpenWeatherMap API documentはこちら。

https://openweathermap.org/current

今回は、緯度と経度をクエリパラメーターにして、天気情報を取得したい。

echoフレームワークでは、クエリパラメータをルートに直接追加する必要はないとのこと。クエリパラメータは動的ではないので、ルート定義には含めない。代わりに、ハンドラ関数内でc.QueryParam("lat")c.QueryParam("lon")のようにしてクエリパラメータを取得する。

Go
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ハンドラが呼び出され、ハンドラ内でlatlonのクエリパラメータを取得できる。

天気情報を取得する関数を定義

weather_handler.goを作成し、天気情報を取得するためのハンドラ関数を定義。

OpenWeatherMap APIの、JSON format response例はこちら。

今回はこの中の、天気情報(weather[“description”])と、最高気温(main[“temp_max”])を利用する。

Zsh
                          

{
  "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
}                        

                        
Go
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レスポンスに含まれるdescriptionweatherキーの配列の最初の要素のdescriptionプロパティから取得される。取得したdescriptionは、Go言語のWeatherResponse構造体のDescriptionフィールドに一時保存される。

次に、c.JSON関数を使ってHTTPレスポンスとしてWeatherResponse構造体をJSONにエンコードして返す時、構造体のフィールドタグに定義されたJSONキー名が使用される。例えば、Descriptionフィールドはjson:"description"としてタグ付けされているため、JSONオブジェクトでは"description"キーとしてエンコードされる。

したがって、最終的にクライアントに返されるJSONレスポンスは以下のようになる。

Zsh
{
  "description": "晴れ時々雲",
  "temp_max": 30.0
}

ここで、descriptiontemp_maxはそれぞれWeatherResponse構造体のフィールドタグで指定された名前。このようにして、Go言語の構造体とJSONキー名との間で名前のマッピングを行う。

アプリ回収

ApiClientクラスにgetWeatherメソッドを作成

今までは、アプリ側にOpenWeatherMAPのAPIキーを埋め込んでAPIにアクセスしていたが、今回の改修に伴い、ApiClientクラスにバックエンド経由で情報を取得するgetWeatherメソッドを作成

api_client.dart

Go
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

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()など)するのではなく、外部からコンストラクタ経由で受け取る。これにより、WeatherServiceApiClientの具体的な実装に依存しなくなり、テストや再利用が容易になる。

下記のように、WeatherServiceのインスタンス生成時にApiClientのインスタンスを注入。

Dart
final weatherService = WeatherService(apiClient: someApiClientInstance);

Riverpodによる状態管理

Providerを使用してWeatherServiceのインスタンスを生成し、そのインスタンスをアプリケーションの他の部分で利用できるようにする。

https://riverpod.dev/ja/docs/concepts/reading

Dart
final weatherServiceProvider = Provider<WeatherService>((ref) {
	// 'ref'オブジェクトを通じてapiClientプロバイダを利用
  final apiClient = ref.watch(apiClientProvider);
  return WeatherService(apiClient: apiClient);
});

HomeTabウィジェット

WeatherServiceProviderを使用して、非同期に天気情報を取得し、それをUIに反映する。

Dart
// 既存コード
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アドレスを確認する必要があるので、ターミナルで

Zsh
$ ifconfig | grep 'inet '

とコマンドを打ったところ

Zsh
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”

コメント

タイトルとURLをコピーしました