Реализация моего проекта. Часть 1. Web-приложение
Всем привет. Это первая статья из цикла «Как я делаю свой проект». Здесь пойдет речь не о самом сервисе, а о том, как он реализовывается.
Кратко об архитектуре всего сервиса:
- Слой данных и бизнес-логики: СУБД PostgreSQL 9.4
- Средний слой: Node.js (Express) + Redis. Реализация REST API, шардинг (горизонтальное масштабирование)
- Клиенты:
- 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 — вспомогательные библиотеки
<!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).
@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>
- layout.eco — определяем регионы, где будут отображатся представления и их расположение
Сборка проекта
На первом этапе разработки я использовал 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.
Ссылки:
- От Backbone.js к Marionette.js
- Скринкасты по Marionette.js (и не только) от Brian Mann
- Сам проект
- API проекта
- Исходный код
Автор: amiksam