Pull to refresh

Пишем Penguin Daycare Simulator на Go (Google App Engine) и Lua (Corona SDK)

Reading time 13 min
Views 9.5K

1. Введение


Данный проект представляет собой простой пример использования Google App Engine в мобильном приложении.

Cерверная часть предоставляет список пингвинов в формате JSON. Мобильный клиент запрашивает этот список по HTTP или HTTPS.
Также серверная часть ведёт запись определённых событий в базу данных, а именно количество посещений конкретного пингвина и количество нажатий кнопок: скормить рыбку и почесать животик.
У каждого пингвина есть поля описания Name, Bio и поля счётчиков.

2. Тонкости перевода


Думал как можно перевести Penguin Daycare Simulator на русский язык, но «детский сад» в качестве «daycare» не подходит, «дневной уход» тоже. Поэтому так и осталось без перевода.

3. Подготовка


Если у вас не установлен Google App Engine Go SDK, то переходите по ссылке Google App Engine, нажимайте «Try it now» и следуйте всем пунктам. Дайте имя своему проекту, выберите Go, скачайте и установите SDK. Убедитесь, что у вас корректно установлены переменные окружения (PATH, GOROOT, GOPATH, APPENGINE_DEV_APPSERVER), для этого в терминале у вас должна быть видна команда goapp. Забегая вперёд, скажу, что для загрузки простого проекта на сервер GAE и его запуска нужно выполнить команду goapp deploy в директории проекта. Она спросит у вас email гугло-аккаунта, на котором должен быть расположен проект. Важно чтобы имя проекта совпадало в app.yaml и на сайте. Но в данном проекте используются модули и процесс загрузки несколько отличается.

В качестве IDE для Go я рекомендую LiteIDE, а для Lua и Corona SDK — ZeroBrane Studio. Скачать Corona SDK можно на их сайте.

4. Клинт-сервер


На картинке ниже представлена очень сложная схема общения между клиентом (слева) и сервером (справа).


Как видно клиент запрашивает только список пингвинов и отсылает только три события. Общение ведётся по HTTP, но можно использовать и HTTPS совершенно бесплатно. Это можно отнести к одному из плюсов использования GAE — нет необходимости платить за SSL сертификат и настраивать работу с ним.

Так как всё работает по HTTP, то можно непосредственно в браузере выполнять запросы без использования специального клиента.

penguin-daycare-simulator.appspot.com
Простое приветствие, не используется мобильным клиентом, но позволяет сказать работает ли сервис. Можете заменить http на https и убедиться, что так тоже работает.

penguin-daycare-simulator.appspot.com/penguins
Это самый важный запрос. С его помощью мобильный клиент получает список всех пингвинов, которые в данный момент находятся под присмотром.
Для более удобного просмотра этих данных я рекомендую расширение JSONview для Chrome.

penguin-daycare-simulator.appspot.com/stat/visit
penguin-daycare-simulator.appspot.com/stat/fish
penguin-daycare-simulator.appspot.com/stat/bellyrub
Эти три запроса увеличивают соответствующие счётчики для какого-либо пингвина. Id пингвина передаётся в качестве POST параметра. Сервер ничего в ответ не возвращает, но вы можете, если хотите, добавить в ответ строку «OK» или другой сигнал успешного выполнения операции.

5. Ещё скриншоты, больше скриншотов!




Уже перед публикацией статьи, вспомнил про этого пингвинчика:
Смотреть позитиватор

6. Серверная часть — Google App Engine


Теперь можем перейти непосредственно к коду. Рассмотрим файловую структуру проекта на Go.
PenguinDaycareSimulatorServer/
├── default/
│   ├── app.go
│   ├── default.yaml
│   └── penguins.json
├── static/
│   ├── favicon.ico
│   └── static.yaml
└── dispatch.yaml

default и static — это модули. Проект для GAE может быть разбит на модули, а может работать и без них. В этом случае нужны только три файла: app.yaml, app/app.go и penguins.json. Изначально так и было в моём проекте (можно посмотреть первый коммит на GitHub), но мне захотелось добавить настройку max_concurrent_requests, которая отвечает за то, сколько одновременных запросов может обрабатывать один instance вашего приложения. Значение по умолчанию — всего 10. Go явно способен на большее. Максимальное значение — 500. При росте нагрузки и превышении этого значения, запускаются дополнительные копии вашего приложения и нагрузка распределяется между ними. Если хотите укладываться только в бесплатные квоты для GAE, то использование этой настройки крайне желательно. Если приложение не справляется с такой нагрузкой, то снижайте это значение и переходите на платный биллинг.

Так вот эта настройка доступна только для модулей. И в вашем приложении должно быть минимум 2 модуля, чтобы GAE посчитал его модульным.

static — очень простой модуль, без которого можно было бы и обойтись (если бы не ограничение GAE выше), его задача только в том, чтобы отдавать статично файл favicon.ico.

default — основной модуль, который и выполняет всю работу.

Файлы *.yaml — это настройки и описания. По одному на каждый модуль и один файл dispatch.yaml, который описывает какие URL какой модуль обрабатывает.
dispatch.yaml
application: penguin-daycare-simulator

dispatch:
- url: "*/favicon.ico"
  module: static

- url: "*/"
  module: default

static.yaml
application: penguin-daycare-simulator
module: static
version: 1
runtime: python27
api_version: 1
threadsafe: true

handlers:
- url: /favicon.ico
  static_files: favicon.ico
  upload: favicon.ico

default.yaml
application: penguin-daycare-simulator
module: default
version: 1
runtime: go
api_version: go1
automatic_scaling:
  max_concurrent_requests: 500

handlers:
- url: /.*
  script: _go_app

Обратите внимание, что в static.yaml runtime указан Python, а не Go. Это сделано потому, что GAE ругается, если пытаетесь загрузить модуль на Go без собственно Go файлов. Однако он не ругается на Python и PHP при такой ситуации.
off topic
Внимательный читатель здесь может возразить мол «чем PHP хуже Python для отдачи статичных файлов» и попытаться развязать holywar, но Python лично мне ближе, поэтому и выбрал его. Любой другой может использовать PHP для этих целей. Конечно, это всё бессмысленно, так как ни Python, ни PHP не участвуют в этом процессе.

handlers в default.yaml указывает какие исполняемые файлы обрабатывает определённые URL. В нашем случае app.go обрабатывает все приходящие запросы (с учётом dispatch.yaml). Описание URL очень гибкое, использует регулярные выражения. Однако если для Python и PHP можно использовать разные файлы для обработки разных URL внутри одного модуля, то для Go это должен быть один единственный файл, который обозначается как "_go_app". Дальше уже внутри программы на Go можно выделить обработчики для разных URL и разбить всё приложение на несколько файлов, если необходимо.

Больше про настройку и yaml файлы можно почитать тут.

penguins.json — файл в формате JSON, содержащий в себе имена и описание всех используемых пингвинов.
penguins.json
[
	{"id": "1",
	"name": "Tux",
	"bio": "Beloved Linux mascot"
	},
	{"id": "2",
	"name": "Skipper",
	"bio": "Small combat squad leader"
	},
	{"id": "3",
	"name": "Lolo",
	"bio": "Russian adventurer"
	},
	{"id": "4",
	"name": "Gunter",
	"bio": "The darkest character in Adventure Time"
	},
	{"id": "5",
	"name": "The Penguin",
	"bio": "Na, na, na, na, na, na, na, na, na, na... The Penguin! "
	}
]

Добавление, редактирование пингвинов происходит через этот файл.

Теперь мы подошли к app.go — сердцу всего приложения. Полный листинг удобно смотреть сразу на GitHub — app.go.

Упрощённая структура этого файла:
package app

Перечисление всех используемых библиотек.
import (...)

Структура каждого пингвина: Id, имя, описание, счётчики.
type penguin struct {...}

Слайс (массив) всех пингвинов.
var penguins []penguin

Структура записи в базу данных.
type penguinEntity struct {...}

Инициализация.
func init() {...}

Чтение penguins.json в слайс penguins.
func loadPenguinsJson() {...}

Обработчик / - вывод простого сообщения.
func rootHandler(w http.ResponseWriter, r *http.Request) {...}

Обработчик /penguins - вывод всех пингвинов со статистикой в формате JSON.
func penguinsHandler(w http.ResponseWriter, r *http.Request) {...}

Обработчик события /stat/visit - посещение пингвина.
func visitHandler(w http.ResponseWriter, r *http.Request) {...}

Обработчик события /stat/fish - кормление пингвина рыбкой.
func fishHandler(w http.ResponseWriter, r *http.Request) {...}

Обработчик события /stat/bellyrub - почёсывание пингвина по животику.
func bellyrubHandler(w http.ResponseWriter, r *http.Request) {...}

При запуске приложения первым делом запускается функция init(), которая производит чтение из файла penguins.json и устанавливает какая функция в ответе за разные запросы со стороны клиента. Вы уже могли ими воспользоваться по ссылкам в начале статьи.

penguinsHandler() сериализует слайс penguins в JSON формат функцией json.Marshal() и отдаёт клиентам через fmt.Fprint().

visitHandler(), fishHandler(), bellyrubHandler() действуют по одной логике — берём пингвина из базы данных, увеличиваем на единицу соответствующий параметр и записываем обратно в базу данных. База данных — Datastore — не является SQL совместимой, то есть она представляет собой NoSQL решение. Описание её работы достойно отдельной статьи.

Так как многие операции на GAE тарифицируются отдельно, в том числе и доступ к Datastore, то следует избегать излишнего использования ресурсов. Так, например, при запросе статистики по всем пингвинам совершенно необязательно предоставлять актуальные данные. Можно кэшировать этот запрос с временем жизни кэша скажем 10 минут. Для этого я ввёл дополнительную переменную lastUpdateTime — метку времени последнего обновления слайса penguins. А при каждом запросе /penguins я вызываю функцию updatePenguinsStatistics(), которая проверяет не истекло ли время жизни кэша и в цикле обновляет показания счётчиков для каждого пингвина в слайсе penguins.

Чтобы форсировать обновление вручную, я ввёл дополнительный запрос /update и соответствующий обработчик updateHandler().

Каждый запрос обрабатывается в собственной goroutine, поэтому нужно защитить слайс penguins от возможной одновременной записи или чтения во время записи. Для этого используется RWMutex — мьютекс на чтение или запись. Его использование более эффективно, чем простого Mutex.

Для избежания платного потребления ресурсов, можно также ввести отложенную запись в базу данных, накапливая значения всех поступивших событий.

Для загрузки проекта на сервер GAE нужно выполнить три команды в терминале в директории проекта:
goapp deploy default/default.yaml
goapp deploy static/static.yaml
appcfg.py update_dispatch .

В дальнейшем при изменении app.go, необходимо только будет запускать goapp deploy default/default.yaml.

В заключении про серверную часть скажу, что для увеличения бесплатных лимитов я рекомендую подключить платный биллинг, но при этом задать максимальную стоимость в день равную $1. При этом некоторые бесплатные квоты увеличиваются, а вы всё ещё ничего не тратите.

7. Клиентская часть — Corona SDK


Corona SDK — это кроссплатформенный фреймворк для разработки мобильный приложений под Android, iOS, Windows Phone (скоро) и HTML5 (в разработке). Использую данный продукт уже довольно давно, пишу игры как для клиентов в качестве фрилансера, так и для себя. Отмечу достойную скорость работы и быстроту создания приложений.

Начнём тоже с файловой структуры проекта. Файлов здесь больше, в основном за счёт иконок и картинок, поэтому убираю под спойлер.
файловая структура
PenguinDaycareSimulator/
├── images/
│   ├── penguins/
│   │   ├── 1.png
│   │   ├── 1@2x.png
│   │   ├── 2.png
│   │   ├── 2@2x.png
│   │   ├── 3.png
│   │   ├── 3@2x.png
│   │   ├── 4.png
│   │   ├── 4@2x.png
│   │   ├── 5.png
│   │   └── 5@2x.png
│   ├── background.jpg
│   ├── background@2x.jpg
│   ├── button-over.png
│   ├── button-over@2x.png
│   ├── button.png
│   ├── button@2x.png
│   ├── dot-off.png
│   ├── dot-off@2x.png
│   ├── dot.png
│   ├── dot@2x.png
│   ├── fish.png
│   ├── fish@2x.png
│   ├── hand.png
│   ├── hand@2x.png
│   ├── popup.png
│   └── popup@2x.png
├── lib/
│   ├── api.lua
│   ├── app.lua
│   └── utils.lua
├── scenes/
│   ├── choose.lua
│   ├── menu.lua
│   └── penguin.lua
├── Default-568h@2x.png
├── Icon-60.png
├── Icon-60@2x.png
├── Icon-72.png
├── Icon-72@2x.png
├── Icon-76.png
├── Icon-76@2x.png
├── Icon-Small-40.png
├── Icon-Small-40@2x.png
├── Icon-Small-50.png
├── Icon-Small-50@2x.png
├── Icon-Small.png
├── Icon-Small@2x.png
├── Icon-hdpi.png
├── Icon-ldpi.png
├── Icon-mdpi.png
├── Icon-ouya.png
├── Icon-xhdpi.png
├── Icon-xxhdpi.png
├── Icon.png
├── Icon@2x.png
├── build.settings
├── config.lua
└── main.lua

Можете пока обратить внимание только на Lua файлы.

config.lua, build.settings — файлы настройки проекта для Corona SDK. Указывают портретный или ландшафтный вид имеет приложение, опорное разрешение экрана, способ масштабирования и другие разные настройки. Если Corona SDK для вас в новинку, то можете не обращать пока внимание на эти файлы.

Также в корне вы найдёте кучу иконок под iOS и Android, плюс Default-568h@2x.png для корректной работы на iPhone 5. Внутри директории images/ есть обычные файлы и их удвоенные HD версии @2x. Сейчас в принципе уже можно не поддерживать устройства с экранами вроде iPhone 3GS, их процент очень мал, но тем не менее отличен от нуля. Для полноценной поддержки iPad Retina вам уже нужны будут @4x файлы и строчка в config.lua, но большинство игр и так нормально работают.

Corona SDK запускает приложение начиная с файла main.lua, в нём подключаются нужные библиотеки, объявляются некоторые переменные и происходит переход на сцену с кнопкой «Enter the Daycare». Все сцены (экраны) приложения хранятся в разных файлах и собраны в директории scenes/, а все пользовательские библиотеки я разместил в lib/. Разработчик волен располагать эти файлы как ему захочется, я предпочитаю так.

В lib/ находятся app.lua и utils.lua — вместе это мой сборник полезных функций для работы с Corona SDK. В app.lua реализованы удобные обёртки над стандартными функциями Corona SDK для отображения картинок, текста, кнопок и др.

Переход из main.lua в scenes/menu.lua осуществляется через строчку
storyboard.gotoScene('scenes.menu')

Где, в свою очередь, уже выполняется запрос пингвинов на сервере. Вот основной кусок кода из menu.lua.
function scene:createScene (event)
    local group = self.view

    app.newText{g = group, text = 'Penguin Daycare', size = 32, x = _CX, y = _CY - 150}
    app.newText{g = group, text = 'Simulator', size = 32, x = _CX, y = _CY - 110}

    local pleaseWait = app.newText{g = group, text = 'Please Wait', size = 16, x = _CX, y = _CY}
    local button = app.newButton{g = group, x = _CX, y = _CY,
        text = 'Enter the Daycare',
        onRelease = function()
            storyboard.gotoScene('scenes.choose', {effect = 'slideLeft', time = app.duration})
        end}
    button.isVisible = false

    app.api:getPenguins(function()
            pleaseWait.isVisible = false
            button.isVisible = true
        end)
end

Создаются три строчки текста и одна кнопка. Кнопка спрятана до тех пор, пока мы не получим ответ от сервера. Сам запрос выполняется функцией app.api:getPenguins(), в качестве аргумента у неё callback-функция.

После нажатия на кнопку мы попадаем на сцену выбора пингвина, тоже приведу только основную часть кода из файла choose.lua.
function scene:createScene(event)
    local group = self.view

    self.backButton = app.newButton{g = group, x = _L + 10, y = _T + 10, w = 48, h = 32, rp = 'TopLeft',
        text = 'Back',
        fontSize = 14,
        onRelease = function()
            storyboard.gotoScene('scenes.menu', {effect = 'slideRight', time = app.duration})
        end}

    local function gotoPenguin(ind)
        storyboard.gotoScene('scenes.penguin', {effect = 'slideLeft', time = app.duration, params = ind})
    end
    local slideView = newSlideView{g = group, x = 0, y = _CY, dots_y = 180, onRelease = gotoPenguin}
    for i = 1, #app.api.penguins do
        local p = app.api.penguins[i]
        local slide = display.newGroup()
        app.newImage('images/popup.png', {g = slide, w = 300, h = 335})
        app.newImage('images/penguins/' .. p.id .. '.png', {g = slide, w = 200, h = 256})
        app.newText{g = slide, x = 0, y = -140, text = p.name, size = 18, color = 'white'}
        app.newText{g = slide, x = 0, y = 140, text = p.bio, size = 14, color = 'white', w = 220, align = 'center'}
        slideView:addSlide(slide)
    end

    slideView:makeDots()
    slideView:gotoSlide(1)
end

Здесь newSlideView() это функция, создающая простой виджет, с помощью которого можно пролистывать слайды с пингвинами. Код этого виджета располагается тут же в choose.lua в начале файла.

Для каждого пингвина создаётся слайд. Изображения пингвинов хранятся внутри приложения и соответствуют Id пингвинов. Это дело можно исправить путём хранения изображений на сервере GAE или любом другом. Для загрузки картинок из сети в Corona SDK есть функция display.loadRemoteImage() или более низкоуровневая network.download().

По нажатию на слайд вызывается функция gotoPenguin(), которая получает номер (не Id) пингвина в массиве (table) всех полученных пингвинов. Эта функция производит переход на заключительную сцену penguin.lua, передавая этой сцене тот же самый аргумент.
penguin.lua
function scene:createScene(event)
    local group = self.view
    local background = app.newImage('images/background.jpg', {g = group, w = 384, h = 640, x = _CX, y = _CY})

    self.backButton = app.newButton{g = group, x = _L + 10, y = _T + 10, w = 48, h = 32, rp = 'TopLeft',
        text = 'Back',
        fontSize = 14,
        onRelease = function()
            storyboard.gotoScene('scenes.choose', {effect = 'slideRight', time = app.duration})
        end}

    local ind = event.params
    local p = app.api.penguins[ind]

    local visitsLabel = app.newText{g = group, x = _CX, y = _T + 20, text = 'Visits: ' .. p.visit_count, size = 18, color = 'white'}
    local fishLabel = app.newText{g = group, x = _CX, y = _T + 40, text = 'Fish: ' .. p.fish_count, size = 18, color = 'white'}
    local bellyrubsLabel = app.newText{g = group, x = _CX, y = _T + 60, text = 'Belly rubs: ' .. p.bellyrub_count, size = 18, color = 'white'}
    local penguin = app.newImage('images/penguins/' .. p.id .. '.png', {g = group, w = 200, h = 256, x = _CX, y = _CY - 25})

    app.newButton{g = group, x = _CX - 80, y = _B - 50, w = 128, h = 48,
        text = 'Fish',
        fontSize = 14,
        onRelease = function()
            local fish = app.newImage('images/fish.png', {g = group, x = penguin.x, y = penguin.y + 200, w = 512, h = 188})
            fish.alpha = 0.8
            transition.to(fish, {time = 400, alpha = 1, y = penguin.y, xScale = 0.1, yScale = 0.1, transition = easing.outExpo, onComplete = function(obj)
                    transition.to(fish, {time = 400, alpha = 0, onComplete = function(obj)
                            display.remove(obj)
                        end})
                end})
            app.api:sendFish(p.id)
            p.fish_count = p.fish_count + 1
            fishLabel:setText('Fish: ' .. p.fish_count)
        end}

    app.newButton{g = group, x = _CX + 80, y = _B - 50, w = 128, h = 48,
        text = 'Belly rub',
        fontSize = 14,
        onRelease = function()
            local hand = app.newImage('images/hand.png', {g = group, x = penguin.x - 40, y = penguin.y + 30, w = 80, h = 80, rp = 'TopLeft'})
            transition.to(hand, {time = 1200, x = penguin.x + 40, transition = easing.swing3(easing.outQuad), onComplete = function(obj)
                    display.remove(obj)
                end})
            app.api:sendBellyrub(p.id)
            p.bellyrub_count = p.bellyrub_count + 1
            bellyrubsLabel:setText('Belly rubs: ' .. p.bellyrub_count)
        end}

    app.api:sendVisit(p.id)
    p.visit_count = p.visit_count + 1
    visitsLabel:setText('Visits: ' .. p.visit_count)
end

В penguin.lua происходит загрузка фонового изображения, изображения выбранного пингвина, отображение нескольких текстовых меток и двух кнопок-действий. При нажатии на них происходит анимация действия и отправка запроса на сервер через функции app.api:sendFish() и app.api:sendBellyrub(). А app.api:sendVisit() вызывается сразу после создания сцены. После вызова каждой из этих функций обновляются соответствующие текстовые метки, даже если нет интернета. Это можно исправить, введя проверку на получение ответа от сервера по каждому запросу и предоставляя callback-функции.

Наконец, вся работа с сервером осуществляется в файле lib/api.lua.
api.lua
local _M = {}
local app = require('lib.app')
_M.hostname = 'http://penguin-daycare-simulator.appspot.com'

function _M:getPenguins(callback)
    local url = '/penguins#' .. math.random(1000, 9999)
    network.request(self.hostname .. url , 'GET', function (event)
        if not event.isError then
            local response = json.decode(event.response)
            if response then
                self.penguins = response
                callback()
            end
        end
    end)
end

function _M:sendVisit(id)
    local url = '/stat/visit'
    local request = {body = 'id=' .. id}
    network.request(self.hostname .. url , 'POST', function (event)
        if event.isError then
            app.alert('Network error')
        end
    end, request)
end

function _M:sendFish(id)
    local url = '/stat/fish'
    local request = {body = 'id=' .. id}
    network.request(self.hostname .. url , 'POST', function (event)
        if event.isError then
            app.alert('Network error')
        end
    end, request)
end

function _M:sendBellyrub(id)
    local url = '/stat/bellyrub'
    local request = {body = 'id=' .. id}
    network.request(self.hostname .. url , 'POST', function (event)
        if event.isError then
            app.alert('Network error')
        end
    end, request)
end

return _M 

Как можно было догадаться, работа с сервером производится простыми POST запросами. В случае getPenguins(), ответ от сервера конвертируется из JSON формата в массив (table) функцией json.decode() и помещается в поле (property) модуля.

Как видите, посылать POST запросы и реагировать на их ответы в Corona SDK очень просто. Соответственно очень простая вышла и сама интеграция с Google App Engine. Я не расписываю что делает каждая строчка, надеюсь синтаксис интуитивно понятен.

8. Ссылки


Исходники лежат у меня на GitHub:

Можно установить клиентскую часть на Android 2.3.3+, вот APK (mirror).
Либо скачивайте Corona SDK, скачивайте исходники с GitHub и запускайте в Corona Simulator.

Спасибо M0sTH8 за помощь в написании статьи.

Подписывайтесь на мой твиттер @SergeyLerg

На этом всё. Спасибо за внимание!
Tags:
Hubs:
+15
Comments 0
Comments Leave a comment

Articles