Реализация моего проекта. Часть 1. Web-приложение

Всем привет. Это первая статья из цикла «Как я делаю свой проект». Здесь пойдет речь не о самом сервисе, а о том, как он реализовывается.

Кратко об архитектуре всего сервиса:

  1. Слой данных и бизнес-логики: СУБД PostgreSQL 9.4
  2. Средний слой: Node.js (Express) + Redis. Реализация REST API, шардинг (горизонтальное масштабирование)
  3. Клиенты:
    • web-приложение
    • мобильное приложение


Сразу скажу, что API сервиса полностью открыты как и исходный код Web-приложения (в будущем и исходники мобильного приложения будут выкладываться на github)

В данной статье будет рассматриваться Web-приложение.

Итак, я использую:

  • CoffeeScript. Так получилось, то я изучал Marionette.js по скринкастам от Brian Mann, а он использовал Ruby On Rails и CoffeeScript. Позже я отказался от Ruby On Rails, а вот CoffeeScript прижился.
  • Marionette.js — MVC фреймворк (для знакомства можно почить здесь)
  • Gulp — сборка проекта
  • Bower — пакетный менеджер

Что из себя представляет это web-приложение? Это несколько маленьких статических страниц (регистрация, подтверждение регистрации, сброс пароля, подробное описание сервиса) и одна главная страница-приложение (Single-Page Application).

Файловая структура проекта

  • _ — главная страница сайта
  • _<page_name> — страница первого уровня
  • _<page_name>__<sub_page_name> — страница второго уровня
  • bower_components
  • lib — общие библиотеки для всех страниц
  • node_modules
  • public — собранный проект

Файловая структура страницы

Файловая структура страницы

Файл app/assets/javascripts/app.coffee> — это «точка входа» для страницы.

Папка app/assets/javascripts/backbone нужна только для SPA.

  • apps — содержит модули-приложения. Своего рода это «кирпичики», из которых строится web-приложение. Они полностью независимые друг от друга и общение между ними идет только через шину сообщений.
  • entities — описание моделей и коллекции
  • lib — вспомогательные библиотеки

Шаблон для index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <link href="/_<page_name>[/<sub_page_name>]/assets/app.css" media="screen" rel="stylesheet"/>
</head>
<body>


<script src="/_<page_name>[/<sub_page_name>]/assets/app.js"></script>
</body>
</html>

Данные приложения

Все глобальные коллекции и модели хранятся в объекте App.entities. Доступ в приложении к сущностям может быть как прямой (App.entities.projects), так и через глобальную шину сообщений: App.request(‘project:entities’)

Стандартно для каждой сущности есть две функции:

  • App.request(‘project:entities’) — возвращается коллекция сущностей
  • App.request(‘project:new:entity’) — возвращается новая сущность

Запуск приложения

  • Пользователь со страницы входа отправляет запрос на аутентификацию. Если все в порядке, то запрос возвращает токен авторизации, который нужно передавать со всеми будущими запросами к серверу. Этот токен сохраняется в sessionStorage браузера и делается редирект на страницу /i. Это и есть SPA.
  • Отрабатывается функция входа на страницу /i, где проверяется наличие токена авторизации в sessionStorage. Если все в порядке, то запускается приложение (см. Marionette.Application).
  • Делается один запрос на получения справочников и другой необходимой информации.
  • Запускаются модули-приложения бокового меню и шапки.

Модули-приложения

Все остальные модули-приложения работает только с основной частью страницы — главным регионом. Запускаются они через смену адресной строки (см. Marionette.AppRouter)

Для примера рассмотрим модуль «Проекты» (backbone/apps/references_projects).

Файловая структура модуля

Описание модели и коллекции проектов (файл backbone/entities/project.coffee)

@CashFlow.module 'Entities', (Entities, App, Backbone, Marionette, $, _) ->
  class Entities.Project extends Entities.Model
    idAttribute: 'idProject'
    urlRoot: App.getServer() + '/projects'

    defaults:
      idUser: null
      idProject: null
      name: ''
      writers: []
      note: ''

    parse: (response, options)->
      if not _.isUndefined response.project
        response = response.project
      response

  # --------------------------------------------------------------------------------

  class Entities.Projects extends Entities.Collection
    model: Entities.Project
    url: 'projects'

    parse: (response, options)->
      response.projects

    initialize: ->
      @on 'change:name', =>
        @sort()

    comparator: (project) ->
      project.get('name')

  API =
    newProjectEntity: ->
      new Entities.Project

    getProjectEntities: (options = {})->
      _.defaults options,
        force: false
      {force} = options

      if !App.entities.projects
        App.entities.projects = new Entities.Projects
        force = true

      projects = App.entities.projects

      if force
        projects.fetch
          reset: true

      projects

  # --------------------------------------------------------------------------------

  App.reqres.setHandler 'project:new:entity', ->
    API.newProjectEntity()

  App.reqres.setHandler 'project:entities', (options)->
    API.getProjectEntities(options)
    

Модуль состоит из:

  • Основной файл. Здесь описываются роутеры и обработчики вызовов через общую шину сообщений (внешний интерфейс модуля, через который другие модули могут с ним взаимодействовать)
    references_projects_app.coffee

    @CashFlow.module 'ReferencesProjectsApp', (ReferencesProjectsApp, App, Backbone, Marionette, $, _) ->
      class ReferencesProjectsApp.Router extends Marionette.AppRouter
        appRoutes:
          'references/projects': 'list'
    
      API =
        list: ->
          ReferencesProjectsApp.list()
    
      App.addInitializer ->
        new ReferencesProjectsApp.Router
          controller: API
    
      @list = (project) ->
        new ReferencesProjectsApp.List.Controller
          project: project
    
      @edit = (project, region) ->
        new ReferencesProjectsApp.Edit.Controller
          project: project
          region: region
    
      # --------------------------------------------------------------------------------
    
      App.reqres.setHandler 'project:edit', (project, region = App.request 'dialog:region') ->
        isNew = project.isNew()
        editController = ReferencesProjectsApp.edit project, region
    
        editController.on 'form:after:save', (model) ->
          if isNew
            projects = App.request 'project:entities'
            projects.add model
          editController.form.formLayout.trigger 'dialog:close'
    
        editController
                

  • Подмодули. Подмодуль — это реализация одного конкретного действия: показ списка (list), редактирование (edit), копирование(copy) и т.д.

Подмодуль состоит из:

  • controller.coffee — создает и показывает основной layout модуля
    controller.coffee

    @CashFlow.module 'ReferencesProjectsApp.List', (List, App, Backbone, Marionette, $, _) ->
      class List.Controller extends App.Controllers.Application
        initialize: (options = {})->
    
          projects = App.request 'project:entities'
    
          @layout = @getLayoutView projects
    
          # после показа layout показываем панель и список проектов
          @listenTo @layout, 'show', =>
            @showPanel projects
            @showList projects
    
          # показываем layout в главном регионе приложения
          # при смене layout (когда другой модуль начнет работать)
          # все зависимые представления будут автоматически закрыты
          @show @layout
    
        getLayoutView: (projects) ->
          new List.Layout
            collection: projects
    
        showPanel: (projects) ->
          panelView = @getPanelView projects
          @show panelView,
            region: @layout.panelRegion
    
        getPanelView: (projects) ->
          new List.Panel
            collection: projects
    
        showList: (projects) ->
          listView = @getListView projects
          @show listView,
            region: @layout.listRegion
    
        getListView: (projects) ->
          new List.Projects
            collection: projects
    
                

  • view.coffee — описывает представления
    view.coffee

    @CashFlow.module 'ReferencesProjectsApp.List', (List, App, Backbone, Marionette, $, _) ->
      class List.Layout extends App.Views.Layout
        template: 'references_projects/list/layout'
    
        # определяем два региона для панели с кнопками и для таблицы
        regions:
          panelRegion: '[name=panel-region]'
          listRegion: '[name=list-region]'
    
      # --------------------------------------------------------------------------------
    
      class List.Panel extends App.Views.ItemView
        template: 'references_projects/list/_panel'
    
        ui:
          btnAdd: '.btn[name=btnAdd]'
          btnDel: '.btn[name=btnDel]'
          btnRefresh: '.btn[name=btnRefresh]'
    
        events:
          'click @ui.btnAdd': 'add'
          'click @ui.btnDel': 'del'
          'click @ui.btnRefresh': 'refresh'
    
        collectionEvents:
          'sync': 'render'
    
        add: ->
          model = App.request 'project:new:entity'
          App.request 'project:edit', model
    
        del: ->
          model = @collection.getFirstChosen()
          if confirm('Вы уверены, что хотите удалить данный проект?')
            model.destroy()
    
        refresh: ->
          App.request 'project:entities',
            force: true
    
      # --------------------------------------------------------------------------------
      # View для строки таблицы
      class List.Project extends App.Views.ItemView
        template: 'references_projects/list/_project'
        tagName: 'tr'
    
        modelEvents:
          'change': 'render'
    
        events:
          'click a': ->
            @model.choose()
            App.request 'project:edit', @model
    
    
      # --------------------------------------------------------------------------------
    
      # View, который будет показан, если нет проектов
      class List.Empty extends App.Views.ItemView
        template: 'references_projects/list/_empty'
        tagName: 'tr'
    
      #-----------------------------------------------------------------------
    
      # CompositeView для таблицы проектов
      class List.Projects extends App.Views.CompositeView
        template: 'references_projects/list/_projects'
        childView: List.Project
        emptyView: List.Empty
        childViewContainer: 'tbody'
    
        collectionEvents:
          'sync': 'render'
    
                

  • Папка templates — шаблоны, используемые при формировании представлений
    • layout.eco — определяем регионы, где будут отображатся представления и их расположение
      layout.eco

      <div name="panel-region" id="ribbon">
      </div>
      
      <div name="list-region">
      </div>
                          

    • _panel.eco — панель инструментов
      _panel.eco

      <div class="row">
          <div class="col-sm-9">
              <h3 class="page-title">
                  Справочники <span>> Проекты</span>
              </h3>
          </div>
      </div>
      <div class="btn-toolbar" role="toolbar">
          <div class="btn-group">
              <button type="button" name="btnAdd" class="btn btn-default">
                  <i class="fa fa-plus"></i>
                  Добавить
              </button>
              <button type="button" name="btnDel" class="btn btn-default">
                  <i class="fa fa-trash-o"></i>
                  Удалить
              </button>
          </div>
      
          <div class="btn-group">
              <button type="button" name="btnRefresh" class="btn btn-default">
                  <i class="fa fa-refresh"></i>
                  Обновить
              </button>
          </div>
      
          <div class="btn-group">
              <button type="button" name="btnCopy" class="btn btn-default">
                  <i class="fa fa-copy"></i>
                  Копировать
              </button>
              <button type="button" name="btnMerge" class="btn btn-default">
                  <i class="fa fa-code-fork fa-rotate-270 "></i>
                  Объединить
              </button>
          </div>
      </div>
                          

    • _projects.eco — таблица без содержимого
      _projects.eco

      <div class="row">
          <div class="col-xs-12 col-sm-11 col-md-10 col-lg-9">
              <table class="table table-hover table-condensed ">
                  <thead>
                  <tr>
                      <th>Название</th>
                      <th>Владелец</th>
                      <th>Доступ</th>
                      <th>Примечание</th>
                  </tr>
                  </thead>
                  <tbody>
                  </tbody>
              </table>
          </div>
      </div>
                          

    • _project.eco — строка таблицы
      _project.eco

      <td>
          <a href="#">
              <%= @name %>
          </a>
      </td>
      <td>
          <%= CashFlow.entities.users.get(@idUser).get('name') %>
      </td>
      <td>
          <% for idUser in @writers: %>
          <span class="tag">
          <%= CashFlow.entities.users.get(idUser).get('name') %>
          </span>
          <% end %>
      </td>
      <td>
          <%= @note %>
      </td>                    
                          

    • _empty.eco — сообщение, что нет данных
      _empty.eco

      <td colspan="4">
          <div class="well well-sm text-center">
              Нет данных
          </div>
      </td>
                          

Сборка проекта

На первом этапе разработки я использовал Ruby On Rails. Мне очень понравилась идея файлопровода (asset pipeline). Но использовать целый фреймворк только ради файлопровода — не лучшая идея. Поэтому, используя Gulp, написал свой велосипед свою реализацию файлопровода.

Итак, есть два режима сборки проекта: development и production. В production mode происходит сжатие стилей и скриптов. При этом к имени asset-файла добавляется хеш-сумма от содержимого файла (app.js → app-4f2474f8.js).

Еще одна особенность сборки заключается в том, что gulp-задачи генерируются автоматически на основе описания для каждой страницы проекта. Таким образом, что бы добавить сборку новой страницы, достаточно добавить описание что и как собирать.

Есть следующие задачи:

  • scripts:<page_name> — сборка app.js для страницы (проверка CoffeeLint, компиляция CoffeeScript, сжатие)
  • styles:<page_name> — сборка стилей app.css (импорт css-файлов, компиляция SASS, минификация)
  • html:<page_name> — генерация html-страницы (изменение версии сборки, добавление счетчиков, замена имени файлов для app.css и app.js)
  • fonts:<page_name> — копирование шрифтов
  • cp:<page_name> — копирование файлов
  • build:<page_name> — сборка указанной страницы
  • build — сборка проекта
  • watch — наблюдение за файлами и перезапуск соответствующей задачи при изменении файла

Примечание. Не все задачи являются обязательными для каждой страницы.

Пример описания задач для главной страницы

  {
    name: '',
    tasks: {
      scripts: {
        src: [
          'bower_components/jquery1x/dist/jquery.js',
          'bower_components/bootstrap-sass/assets/javascripts/bootstrap/transition.js',
          'bower_components/bootstrap-sass/assets/javascripts/bootstrap/collapse.js',
          'bower_components/social-likes/social-likes.min.js',
          'lib/assets/javascripts/**/*.+(coffee|js)',
          '_/app/assets/javascripts/**/*.+(coffee|js)'
        ],
        dest: 'public/assets'
      },
      styles: {
        src: ['_/app/assets/stylesheets/app.scss'],
        dest: 'public/assets'
      },
      html: {
        src: ['_/app/index.html'],
        dest: 'public'
      },
      fonts: {
        src: ['bower_components/font-awesome/fonts/fontawesome-webfont.*'],
        dest: 'public/assets'
      },
      cp: {
        src: ['_/cp/*'],
        dest: 'public'
      }
    },
    build: ['scripts:', 'styles:', ['html:', 'fonts:', 'cp:']]
  }
    

Примечание: также задается порядок и синхронность выполнения задач при сборки конкретной страницы.
Например, для главной страницы задано следующее правило: [‘scripts:’, ‘styles:’, [‘html:’, ‘fonts:’, ‘cp:’]]. Это значит, что сначало выполнится задача ‘scripts:’, потом ‘styles:’, а уже затем параллельно выполнятся задачи ‘html:’, ‘fonts:’ и ‘cp:’

Файл gulpfile.js целиком на github.

Ссылки:

Автор: amiksam

Источник

Оставить комментарий