Сервіс тимчасових зон на 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 

Дерево проекту має виглядати так:

image

Підемо по порядку. Почнемо з конфіги.

У 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
do before
get '/timezone/name'
end

its(:status) {should eq 400}
its(:body) {should eq error.to_json}
end

context 'with right params' do
do before
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
потрібно :lat, type: Float, desc: 'Latitude'
потрібно :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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.