Сервис временных зон на Ruby и Postgis

В одном из проектов, в которых я учавствовал, возникла задача определения временной зоны по текущей геолокации пользователя. На 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

Ссылки по теме

Код проекта - github.com/lon10/tz-service


18.11.2014
Обсуждение недоступно