Реализация моего проекта. Часть 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.

Ссылки:

ссылка на оригинал статьи http://megamozg.ru/post/15264/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *