Написание сервиса японских кросвордов на gae, backbone, underscore, require и еще с помощью черт знает чего

в 12:37, , рубрики: backbone, Bootstrap, gae, require, underscore, я пиарюсь, метки: , , , , ,

Написание сервиса японских кросвордов на gae, backbone, underscore, require и еще с помощью черт знает чего

Вступление

Многие знают про инфраструктуру от google под названием gae, некоторые считают её слишком проприетарной, другие слишком дорогой. Да она не дешевая, и мы попробуем написать оптимальное приложение для gae, которое жрало бы очень мало ресурсов и в идеале не выходило из бесплатных квот даже при хабраэффекте. Опишу мои ошибки, удачные технологические решения при написания сервиса японских кроссвордов. Фишка сайта в том что он позволяет создавать свои кроссворды и из обычной картинки тоже и делиться ими с друзьями.
Для построения сайта используется след. технологии:
backbone.js — фреймворк для обработки запросов на javascript'е. C его помощью будем надеяться, что уложимся в бесплатные квоты, так как весь код выполняется на клиенте, с сервера запрашиваются только данные о кроссвордах в json формате.
require.js — библиотека для дозагрузки любых ресурсом(js, html), можно указать код, который выполнится после загрузки всех ресурсов. Идеальна если у вас есть на сайте javascript и он используется в 1% случаев, и вы не хотите включать js-файл в index.html, то она вам подойдет.
undescope.js — всякие плюшки для слежения за изменением всего объекта или за конкретным его свойством. Очень большая и крутая библиотека, но я использую её как шаблонизатор.
bootstrap — чтобы не заморачиться с дизайном.
less — не ну, а почему б не использовать? (Потому что мы можем)
Ну и конечно же gae — на чем все это будет крутиться.

Вау это же MVC!!!

Весь наш MVC состоит из трех компонентов модель, вид, контроллер плюс роутер, который знает какой контроллер вызвать.

Роутер

Наш роутер определяет все что идет после # в строке запроса и выбирает какому контроллеру передать управление. Поддерживаются также переменные которые будут переданы в функцию. В начале я использовал загрузку контроллера по требованию, то есть все было разбито на кучу js файлов и при отрисовки любой страницы получалась тройная задержка:

  • задержка на загрузку контроллера
  • у каждого контроллера как минимум есть свой вид + плюс задержка на загрузку вида
  • загрузка данных для страницы.

Предзагрузка контроллеров и, побочно, моделей, и видов для них убирает первые две задержки. Ну а от последней задержки никуда не деться(ну это в том случае, если странице нужны данные). Единственный минус такой системы что все файлы грузятся один за одним, что сильно замедляет процесс загрузки. Далее это будет оптимизировано.
Для меню я создал отдельный контроллер, для удобства. И как оказалось не зря, очень удобно иметь контроллер, который обрабатывают логику меню.
Примерная реализация роутера с комментариями:

require (
  [
    // плагин для запуска кода после того как DOM готов, аналог $(document).ready
    'backbone/domReady',
    // наши контроллеры
    'backbone/views/menu',
    'backbone/views/start',
    'backbone/views/create',
    'backbone/views/image',
    'backbone/views/view_puzzle',
    'backbone/views/list',
    'backbone/views/mylist',
    'backbone/views/search',
    'backbone/views/edit_puzzle',
  ], function(domReady, MenuView, StartView, CreateView, ImageView, PuzzleViewView, ListView, MyListView, SearchView, EditPuzzleView){

  domReady(function () {
    // для рисования меню используется отдельный контроллер
    var menu = new MenuView();
    menu.render();

    var Router = Backbone.Router.extend({
      // так как я не нашел как использовать регулярки,
      // для некоторых запросов у нас две строчки
      routes: {
        "": "start",
        "!/": "start",
        "!/create": "create",
        "!/image": "image",
        "!/list": "list",
        "!/list/:page": "list",
        "!/search/:query": "search",
        "!/search/:query/:page": "search",
        "!/mylist": "mylist",
        "!/mylist/:page": "mylist",
        "!/puzzle/:id": "view_puzzle",
        "!/puzzle/edit/:id": "edit_puzzle"
      },

      start: function () {
        this.show_view(StartView, 'start');
      },

      create: function () {
        this.show_view(CreateView, 'create');
      },

      image: function () {
        this.show_view(ImageView, 'image');
      },

      view_puzzle: function(id) {
        this.show_view(PuzzleViewView, '', id);
      },

      edit_puzzle: function(id) {
        this.show_view(EditPuzzleView, '', id);
      },

      search: function(query, page) {
        this.show_view(SearchView, '', query, page);
      },

      list: function(page) {
        this.show_view(ListView, 'list', page);
      },

      mylist: function(page) {
        this.show_view(MyListView, 'mylist', page);
      },

      show_spinner: function() {
        menu.show_spinner();
      },

      hide_spinner: function() {
        menu.hide_spinner();
      },

      show_view: function(View, view_name, arg1, arg2) {
        this.current_view = new View(arg1, arg2);

        $('.navbar li').removeClass('active');
        if (view_name)
        {
          $('#'+view_name+'_item').addClass('active');
        }

        this.current_view.render();
      }
    });

    window.router = new Router();
    Backbone.history.start();
  });
});
Контроллер

Контроллер, как и любой другой ресурс должен быть определен в стиле:

define(['backbone/text!backbone/templates/start.html'], function(template){
  var StartView = Backbone.View.extend({
    el: "#block",
    template: _.template(template),
    
    // просто рисуем наш шаблон в блоке #block
    render: function () {
      // наши данные для отрисовки шаблона
      var data = {};
      $(this.el).html(this.template(data));
    }
  });

  return StartView;
});

Функцией define определяется ресурс и первый параметр это список зависимостей. В качестве возвращаемого значения должен быть сам ресурс. В моем случае это класс. Странная запись backbone/text!backbone/templates/start.html означает что модули грузятся не сразу с сервера а с помощью плагина text. У библиотеки require есть несколько полезных плагинов:

  1. text — для загрузки шаблонов с сервера. Плюс заключается в том, что шаблоны рендерятся на клиенте, а сервер их отдает статикой, т.е. никакой нагрузки на наш инстанс. Кстати в бесплатной квоте он всего один(если .
  2. i18n — для загрузки файлов локализации.
  3. domReady — для запуска кода после того как DOM готов.
Модель

Модель всего лишь одна — это модель японского кроссворда(Puzzle), у нее есть такие поля как width, height, data, user_data, title. И куча методов для манипулирования ими. Сервис поддерживает не только разгадывание уже готовых кроссвордом, но и создания своих неповторимых. Модель приблизительно может быть представлена так:

define (function(){
  var Puzzle = Backbone.Model.extend({
    urlRoot : '/puzzle/',

    defaults: {
      title: '',
      width: 5,
      height: 5
    },
    // далее куча методов для обеспечения работы модели,
    ...
  });

  return Puzzle;
});
Вид

Вид это самый обычный html файл, в который можно вставлять данные так:

<div id="data"><%= data %></div>

А в тегах <% %> можно писать вообще любой javascript код, даже условия:

<% if (loaded) { %>
   <div>Загружено</div>
<% } else { %>
   <div>Загружаю...</div>
<% } %>

Данные в вид передаются при render контроллера:

var data = {
  'loaded': true
};
$(this.el).html(this.template(data));
Оптимизация

Проект достаточно долго грузится при первой загрузки страницы, но это легко побороть если собрать все ресурсы в один файл и заодно сжать его. Для этого нужен node.js, потому что собирающая программа написана на js. Создаем конфиг, который обрабатывает зависимости для одного файла и создает другой файл:

build.js

({
    baseUrl: "../cross/static/js", 
    name: "common",
    out: "../cross/static/js/common-pro.js"
})

и запуск нашего билдера:
node r.js -o build.js

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

Интеграция с Google analytics

Обидно если мы не сможем определить по каким страницам гуляет пользователь, а будем видеть только первую загрузку сайта. Поэтому код ga пришлось немного изменить. Код был разбит на две части: первая устанавливает настройки аккаунта и загружает скрипты, а вторая дергает загрузку страниц.

1-я часть:

	var _gaq = _gaq || [];
	_gaq.push(['_setAccount', 'UA-ваш-ид']);

	(function () {
		var ga = document.createElement('script');
		ga.type = 'text/javascript';
		ga.async = true;
		ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
		var s = document.getElementsByTagName('script')[0];
		s.parentNode.insertBefore(ga, s);
	})();

2-я часть, срабатывает когда срабатывает роутер. У меня есть одна функция которая рендерит контроллер (show_view) в ней просто добавляем:

        _gaq.push(['_trackPageview', document.location.href]);

Серверная часть

Несколько фактов:

  1. Используется библиотека ndb (справка по которой уже доступна в официальной справке по gae). Выбрана она потому что умеет несколько запросов к бд паковать в один запрос и отправлять на сервер (хотя я не думаю, что где то в простом сервере случается), а также за то что есть встроенный кеш.
  2. Данные получаемые от сервера идут в json формате.
  3. Есть небольшая реализация поиска, которая создает индекс при сохранении кроссворда. Конечно же окончания она не разбирает. Просто парсится слова из заголовка и помещаются список который индексируется (может кому пригодится привожу пример):
    class PuzzleIndex(db.Model):
      keywords = db.StringProperty(repeated=True)
      update_date = db.DateTimeProperty(auto_now=True)
    
      @classmethod
      def index(cls, puzzle):
        index = PuzzleIndex.get_or_insert(str(puzzle.key.id()))
        index.keywords = cls.stemming(puzzle.title)
        index.put()
    
      @classmethod
      def delete_index(cls, puzzle):
        db.Key(PuzzleIndex, str(puzzle.key.id())).delete()
    
      @classmethod
      def stemming(cls, text):
        words = set(re.split(ur's+', text.lower(), re.U))
        return list(filter(None, words))
    
      @classmethod
      def search(cls, text, limit=10, offset=0):
        puzzles = []
    
        words = cls.stemming(text)
    
        query = PuzzleIndex.query()
                        .order(-PuzzleIndex.update_date)
                        .filter(PuzzleIndex.keywords.IN(words))
    
        indexes = query.fetch(limit + 1, offset=offset, keys_only=True)
        if len(indexes):
          keys = [db.Key(Puzzle, int(key.id())) for key in indexes]
          puzzles = db.get_multi(keys)
    
        return puzzles
    

Благодарности

Спасибо моему другу Rainum за помощь с версткой и за логотип.

Заключение

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

Автор: FerumFlex

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js