В одном из проектов, в которых я учавствовал, возникла задача определения временной зоны по текущей геолокации пользователя. На backend приходила запись, создаваемая пользователем с помощью смартфона. Время приходило не в UTC, но в параметрах содержались координаты.
Конечно, существуют готовые сервисы (например The Google Time Zone), но все они платные или сильно ограничены по функционалу. Вот я и решил написать собственный сервис.
Сервис должен быть максимально прост. На него нам нужно делать всего один запрос вида
http://host/timezone/name?lat=55.2341&lng=43.23352
Где lat — это широта, а lng — долгота.
Настравиваем базу данных
В качестве базы данных будем использовать PostgreSQL. Нам также понадобится расширение Postgis, специально заточенное под работу с географическими объектами.
Будем считать, что PostgreSQL у вас уже установлен. Если нет, в интернете много гайдов и туториалов, как это сделать. Процесс установки Postgis также не должен вызывать затруднений — на официальном сайте есть подробная инструкция для большинства популярных операционных систем.
После установки всего необходимого, создадим новую базу данных, которую мы будем использовать для определения временной зоны. В качастве примера, я буду писать «tz_service»:
CREATE DATABASE tz_service
Включим Postgis в нашу базу данных:
CREATE EXTENSION postgis;
CREATE EXTENSION postgis_topology;
CREATE EXTENSION fuzzystrmatch;
CREATE EXTENSION postgis_tiger_geocoder;
Теперь нам понадобится shape-файл всех временных зон с сайта efele.net. Скачиваем tz_world.zip. В архиве лежит файл tz_world.shp. Shape файлы содержат векторное представление географических данных. Но нам надо преобразовать его в SQL-дамп и накатить его на нашу базу «tz_service»:
$ /usr/lib/postgresql/9.1/bin/shp2pgsql -D tz_world.shp > dump.sql
$ psql -d tz_service -f dump.sql
Готово! Проверим работу запросом:
SELECT tzid FROM tz_world WHERE ST_Contains(the_geom, ST_MakePoint(-122.420706, 37.776685));
Должно получиться что-то вроде этого:
tzid
---------------------
America/Los_Angeles
(1 ROW)
Пишем севрис на Ruby
В качестве каркаса сервиса будем использовать фреймворк Grape. Он отлично подходит для быстрого написания REST-like серверных приложений.
Для начала создадим Gemfile и запишем туда необходимые нам гемы:
source "https://rubygems.org"
gem 'rake'
gem 'activerecord'
gem 'pg'
gem 'grape'
group :development, :test do
gem 'shotgun'
gem 'byebug'
gem 'pry'
gem 'pry-byebug'
gem 'rspec'
end
То, что находится в группе development и test необходимо только для разработки и в продакшн-режиме использоваться не будет. А нужно для разработки не так уж и много:
— shotgun для того что бы не перезапускать каждый раз сервер, после очередного изменения кода
— buebug и pry для дебаггинга
— rspec для тестов
Установим все гемы с зависимостями:
$ bundle install
Дерево проекта должно выглядеть так:
Пойдём по порядку. Начнём с конфигов.
В config/database.yml будет содержаться информация для связи с базой данных:
development: &config
adapter: postgresql
host: localhost
username: user
password: password
database: tz_service
encoding: utf8
test:
<<: *config
poduction:
<<: *config
Рядом положим класс конфигурации БД config/configuration.rb для парсинга yaml-файла:
class Configuration
DB_CONFIG = YAML.load_file(File.expand_path('../database.yml', __FILE__))[ENV['RACK_ENV']]
class << self
def adapter
DB_CONFIG['adapter']
end
def host
DB_CONFIG['host']
end
def username
DB_CONFIG['username']
end
def password
DB_CONFIG['password']
end
def database
DB_CONFIG['database']
end
def encoding
DB_CONFIG['encoding']
end
end
end
В app/environment.rb будут содержаться настройки окружения:
require 'bundler'
Bundler.require(:default)
$: << File.expand_path('../', __FILE__)
$: << File.expand_path('../../', __FILE__)
$: << File.expand_path('../../config', __FILE__)
$: << File.expand_path('../services', __FILE__)
ENV['RACK_ENV'] ||= 'development'
require 'grape'
require 'json'
require 'pry'
require 'active_record'
require 'timezone_name_service'
require 'configuration'
require 'application'
require 'time_zone_api'
В app/application.rb пропишем настройки activerecord для коннекта с БД:
ActiveRecord::Base.establish_connection(
adapter: Configuration.adapter,
host: Configuration.host,
database: Configuration.database,
username: Configuration.username,
password: Configuration.password,
encoding: Configuration.encoding
)
Основа для сервиса готова, надо только написать один класс самого сервиса, который будет отвечать на наш запрос и всё. Всё? Нет! Сначала нужно написать тесты. Не стоит забывать о TDD.
Созадим spec/spec_helper.rb и немного настрои его:
ENV['RACK_ENV'] ||= 'test'
require_relative '../app/environment'
require 'rack/test'
RSpec.configure do |config|
config.treat_symbols_as_metadata_keys_with_true_values = true
config.run_all_when_everything_filtered = true
config.filter_run :focus
config.order = 'random'
config.include Rack::Test::Methods
def app
TimeZoneAPI
end
end
В тестах мы должны описать поведение сервиса. А ожидаем мы только две вещи:
1. Адекватный ответ при адекватных параметрах в запросе
2. Ошибку при отсутствии параметров
Опишем это:
describe 'API' do
let(:params) {
{
lat: 55.7914056,
lng: 49.1120427
}
} #отправляемые параметры в запросе
let(:error) {
{ error: 'lat is missing, lng is missing' }
} #ожидаемый ответ при ошибке парсинга параметров
let(:name_response) {
{ timezone: 'Europe/Moscow' }
} #ожидаемый ответ при успешном запросе
#Главная страница
it 'should greet us' do
get '/'
expect(last_response).to be_ok
expect(last_response.body).to eq(%Q{"Welcome to Time Zone API Service"})
end
#Описание процесса получения имени временной зоны
describe 'Timezone name' do
subject {
last_response
}
#Описание различных ситуаций в контекстах
context 'with wrong params' do
before do
get '/timezone/name'
end
its(:status) {should eq 400}
its(:body) {should eq error.to_json}
end
context 'with right params' do
before do
get '/timezone/name', params
end
its(:status) {should eq 200}
its(:body) {should eq name_response.to_json}
end
end
end
Запустив команду:
$ bundle exec rspec
Ни один тест не пройдёт. Ещё бы =) Надо зазеленить тесты.
Нам понадобится обращаться в базу данных с нестандартным запросом. Делать это будем через класс app/services/time_zone_service.rb:
class TimezoneNameService
def initialize(lat, lng)
@lat = lat
@lng = lng
end
def name
#"Нестандартный" запрос. Экранировать координаты нет смысла, так как валидация будет происходить при парсинге
sql = "SELECT tzid FROM tz_world WHERE ST_Contains(geom, ST_MakePoint(#{ActiveRecord::Base.sanitize(@lng)}, #{ActiveRecord::Base.sanitize(@lat)}));"
name_hash = ActiveRecord::Base.connection.execute(sql).first
name_hash['tzid'] rescue nil
end
end
Ну и, наконец, основной класс сервиса app/time_zone_api.rb:
class TimeZoneAPI < Grape::API
format :json
default_format :json
default_error_formatter :txt
desc 'Start page'
get '/' do
'Welcome to Time Zone API Service'
end
namespace 'timezone' do
desc 'Time zone name by coordinates'
params do
requires :lat, type: Float, desc: 'Latitude'
requires :lng, type: Float, desc: 'Longitude'
end #Валидация параметров
get '/name' do
name = TimezoneNameService.new(params[:lat], params[:lng]).name
{ timezone: name }
end
end
end
Вот и всё! Сервис готов. Проверить его работу «вживую» можно запустив Grape-приложение:
$ bundle exec rackup
Ссылки по теме
Фреймворк Grape
Postgis
Код проекта на Github
Автор: lon10