Обратите внимание, что новости можно получать по RSS.
X
-

Информационные технологии

3 марта 2018, 03:36 (2433 дня назад, №11076)Реализация прототипа мобильного/веб клиентов и сервера для системы контроля оборудования предприятий


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

Поскольку полная автоматизация (с контролем всех параметров датчиками и передачей данных с них по сети) технически и организационно невозможна, предлагалось частично автоматизировать процедуру. А именно — в местах проверок (контрольных точках) повесить NFC метки, а сотрудникам, осуществляющим обход, выдать смартфоны с приложением, которое фиксирует прикладывание смартфона к метке и предлагает ввести требуемые для каждой точки параметры. Данные затем передаются на сервер, откуда могут быть просмотрены руководителем в браузере (с отметками выхода за пределы допустимых значений, статистикой и пр).

Для реализации был выбран Javascript, а конкретно — связка в виде NodeJS, React+Redux, React-Native (с обменом между ними через GraphQL). Это позволило использовать один язык для сервера, клиента и мобильного приложения. Прежде с перечисленными технологиями (кроме Javascript) я вплотную не сталкивался, поэтому статья во многом отражает опыт знакомства с ними.

Функционал системы и организация БД


Сущности и их взаимосвязь


'checkpoint' — Контрольная точка, эквивалентна единице оборудования
'param' — Параметры контрольной точки
'unit' — Единица измерения параметра
'range' — Диапазон допустимых для данного параметра значений

'checkup' — Факт обхода контрольных точек по одному из маршрутов
'check' — Факт проверки контрольной точки в ходе обхода (содержит замечания по точке)
'checkvalue' — Фактическое значения одного параметра при проверке контрольной точки

'firm' — Компания
'plant' — Предприятие компании
'shop' — Цех предприятия
'route' — Маршрут по цеху
'rclink' — Привязка контрольной точки к маршруту (определяет порядок посещения точек на данном маршруте )

'position' — Должность пользователя
'user' — Пользователь



Привязка контрольных точек к маршрутам выполняется не напрямую, а через промежуточную rclinks. Это связано с тем, что в рамках одного цеха может быть несколько маршрутов, причём по одним и тем же контрольным точкам — на разных маршрутах, в разном порядке, и необязательно по всем. Поэтому, здесь используется двунаправленный список.

Все описанные сущности представлены в базе данных одноименными таблицами (во множественном числе — т.е. routes, checkpoints и т.д). Идентификаторы записей являются глобальными и генерируются через node-uuid на сервере (в React Native — через react-native-uuid) с добавлением типа (для удобства отладки)

Например: 0ec74560-9da3-11e7-b19a-b5183ad4a4a8_checkpoint

Для каждой записи во всех таблицах фиксируется дата создания записи, дата её изменения (dt_crt, dt_mod), дата последней синхронизации (dt_sync), идентификатор сессии синхронизации (sync_id), а также id пользователя, создавшего или изменившегося запись (user_crt_id, user_mod_id).

Структуры баз на сервере и в мобильном приложении — идентичны (по этой причине для серверной части был выбран SQLite).

Перед началом работы в веб-интерфейсе создаются организации и их структура. Высшим уровнем иерархии является организация (firm), которая содержит производственные объекты (plants). Каждый объект имеет в своём составе цеха (shops). Уровень цехов технически является основным — к ним разрешается доступ должностям (positions), добавляются контрольные точки, в рамках цехов имеются маршруты (routes) и т.д.

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

Контрольные точки


Ключевой сущностью системы является понятие контрольной точки (checkpoint). Смыслом обхода (checkup) является посещение контрольных точек и снятие значений параметров размещённого на них оборудования (checkvalue) для последующего хранения и оценки.

Первоначальное добавление контрольной точки осуществляется в контексте заданного цеха (shop), через веб интерфейс. При этом NFC тэг в форму вносится путем касания смартфоном NFC метки.

Также, в процессе добавления точки к ней привязываются один или несколько параметров (params) и допустимые диапазоны значений (ranges). К параметрам — единицы их измерения (units), которые также привязаны к организации (firms). Т.е. каждая организация может иметь собственный набор единиц измерения.

Параметры могут быть двух типов — float (число) и boolean (значение вида да/нет). Третий тип (text) зарезервирован.

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

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

Порядок задаётся промежуточной таблицей rclinks, где для каждой пары точка-маршрут указан id следующей и предыдущей точки (null для первой и последней).

Процесс обхода


При выполнении обхода мобильное приложение функционирует следующим образом:

Пользователь в предложенном ему, либо выбранном им маршруте нажимает кнопку «Начать обход». При этом создаётся checkup. При выборе контрольной точки пользователь заполняет значениями полученные приложением из params, units поля параметров. При их сохранении, для данной точки в контексте данного обхода создаётся check, в котором сохраняются также замечания по точке и к check привязываются созданные checkvalues, в которых сохраняются значения каждого из введённых параметров.

После сохранения значений параметров точка считается проверенной (т.к. для неё создан check) и ожидается следующая (в соответствии с rclinks.next_id) точка на данном маршруте.
При этом, уже проверенные точки допускается редактировать повторно (до окончания обхода). Пропуск же точек не допускается.

Серверная часть (backend)


Серверная часть реализована на NodeJS, SQLite, GraphQL и позволяет:

1. Хранить данные
2. Обеспечивать доступ к данным из мобильного и веб приложений с соответствующим контролем доступа

Для сервера используется express (с ним стыкуются graphql-server-express и express-jwt). SQLite — через node-sqlite (поддерживает промисы).

Сервер состоит из schema.js, где описываются типы и методы для GraphQL запросов (используется Apollo Server), resolvers.js (с методами, описанными в schema), connectors.js (со всей логикой и SQL запросами к БД) и, собственно, основного server.js

server.js

import express from 'express';
import { graphqlExpress, graphiqlExpress } from 'graphql-server-express';
import bodyParser from 'body-parser';
import cors from 'cors';
import { schema } from './schema';
import { execute, subscribe } from 'graphql';
import { createServer } from 'http';
import { SubscriptionServer } from 'subscriptions-transport-ws';
import db from 'sqlite';
import connectors from './connectors';
import jwt from'express-jwt';
const { GQLSERVERLOCAL, GQLSERVERPROD, WEBSERVER, PATH, DATABASE, AUTH } = require('./config');

let p = db.open(DATABASE.NAME).then(db =>{
    console.error("SQLite: DB ok ");
}).catch(error =>
{
    console.error("SQLite: DB error: " + error);
});

const server = express();

const corsOptions = {
        origin(origin, callback){
                callback(null, true);
        },
        credentials: true
};

server.use(cors(corsOptions));

server.use(PATH.GQL, bodyParser.json(), jwt({
  secret: AUTH.secret,
  credentialsRequired: false,

getToken: function fromHeaderOrQuerystring (req) {
    if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') {
        return req.headers.authorization.split(' ')[1];
    } else if (req.query && req.query.token) {
      return req.query.token;
    }
    return null;
  }

}), graphqlExpress(req => ({
  schema: schema,
  context: {
      user: req.user ? connectors.findUser(req.user.id) : Promise.resolve(null),
    }, // передаётся в context в resolvers.js
})));

// Далее то, что нужно для подписок и обмена сообщениями через websockets
const ws = createServer(server);

server.use(PATH.GIQL, graphiqlExpress({
  endpointURL: PATH.GQL,
  subscriptionsEndpoint: `ws://${GQLSERVERPROD.HOST}:${GQLSERVERPROD.PORT}/subscriptions`
}));

ws.listen(GQLSERVERPROD.PORT, () => {
  console.log(`GraphQL Server v1 is now running on ${GQLSERVERPROD.HOST}:${GQLSERVERPROD.PORT}`);

  new SubscriptionServer({
    execute,
    subscribe,
    schema
  }, {
    server: ws,
    path: PATH.SUBS,
  });
});



scheme.js

[...]

type DeletePlantResult
{
  rows_changed: Int
  error: String
}

input IdsInput {
  ids: [String]
}

deletePlant(data: IdsInput): DeletePlantResult

[...]



resolvers.js

import connectors from './connectors';

export const resolvers = {
  Mutation: {

[...]
deletePlant: (root, params, context) => {
// context содержит то, что в него передано в server.js
[...]

  const errors = [];
  const {data} = params

  const ids = data.ids.map(function(id){
    return "'" + id + "'"
  }).join(",")

  return connectors.deletePlant(ids)
    .then(rows_changed => ({
      rows_changed,
      errors
    }))
    .catch((err) => {
      if (err.code && err.message) {
        errors.push({
          key: err.code,
          value: err.message
        });
        return { errors };
      }
    });
},//deletePlant
 }
[...]
}



connectors.js

deletePlant(ids) {

  return new Promise((resolve, reject) => {

    return db.run(`
      DELETE FROM 'plants' WHERE id IN(${ids})
    `)
        .then((plant) => {
          resolve(plant.changes);
        })
        .catch((err) => {
          return reject(err);
        });
    })
},//deletePlant





Реализована двусторонняя неинкрементальная синхронизация данных между сервером и мобильным приложением. От сервера в мобильное приложение передаются

positions, shops, routes, rclinks, checkpoints, ranges, params, units, users

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

От мобильного приложения на сервер передаются результаты обходов — checkups, checks, checkvalues.

Веб клиент (frontend)


Веб клиент реализован на React + Redux и позволяет:

1. Сформировать в системе организационную структуру организации (промышленные объекты, цеха, пользователи, их должности), схемы и режим обходов (маршруты по цехам, контрольные точки на маршрутах, их параметры)

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

3. Регистрировать и авторизовывать зарегистрированных пользователей, обеспечивать им доступ к ресурсам

Все страницы представлены React компонентами и делятся на две группы — пользовательские (основной интерфейс) и административные (для контроля, выборочного просмотра и редактирования таблиц БД).



Навигация реализована на react-router — в store хранится текущий контекст, в котором находится пользователь, поэтапно заполняемый вызовами updateContext(). В ссылках передаётся только путь к компоненту (кроме административных страниц, где можно указать id объекта в query string). Из данных контекста вверху каждой страницы строится навигационная строка показывающая, где находится пользователь.
Контекст автоматически сохраняется в local storage через redux-persist.

Вёрстка адаптивная (bootstrap), большинство компонентов страниц в основе имеет таблицу на react-bootstrap-table, ячейки которой заполняются результатами GraphQL запроса, параметрами которого являются id объектов, взятые из контекста и query string.
Там, где необходимо, для ячеек определены custom formatters/renderers (отображение дат, редактирование с выбором из списка и т.п.)
Для GraphQL используется Apollo (и в веб клиенте и в мобильном приложении).

Типовой компонент простой страницы с редактируемой таблицей выглядит примерно так:

components/PlantsPage.js:

import React from 'react';
import {  BootstrapTable, TableHeaderColumn } from 'react-bootstrap-table';
import { reduxForm } from 'redux-form';
import { Button, ProgressBar } from 'react-bootstrap';

[...]

class PlantsPage extends React.Component
{
    addEmptyItem() {
     this.props.addPlant({ variables: {data: {firm_id:this.props.params.firm_id, user_crt_id:'system_user',user_mod_id:'system_user'}} });
}

[...]

};

  render() {
    if (this.props.data.loading)
      return  <ProgressBar active bsStyle="info" now={100}  />;

    return (
    <div className="container">
              <h1>Объекты</h1>
      
              <BootstrapTable data={ this.props.data.getPlants.plants }  [...] >

                          <TableHeaderColumn dataField='title' [...] >Название</TableHeaderColumn>

                          [...]
    
          </BootstrapTable>

      <Button [...] onClick={ () => { this.addEmptyItem(); } }>
        Добавить
      </Button>

    </div>
    )
  }
}

[...]

export default PlantsPage;



components/PlantsContainer.js:

import React from 'react';
import { withRouter } from 'react-router';
import { connect } from 'react-redux';
import gql from 'graphql-tag';
import { graphql, compose } from 'react-apollo';
import PlantsPage from "../components/PlantsPage";

class PlantsContainer extends React.Component {

  render()
  {
      return <PlantsPage {...this.props}  />;
  }

}//class

const gqlGetPlants = gql`
        query getForPlants($firm_id:String!,$object_id:String!,$holding_id:String!) {
          getPlants(firm_id: $firm_id) {
            plants {
              id,
              title,
              shops_count,
                [...]
            },
            errors
            {
              key,
              value,
            }
          },
         }
      `;

const gqlGetPlantsProps =
  {
  options: (ownProps) => ({
    pollInterval: 0,
    variables: {
      firm_id: ownProps.params.firm_id,
      object_id: ownProps.params.firm_id,
      holding_id: 'all', 
    },
  }),
}

const gqlAddPlant = gql`
  mutation ($data: PlantInput) {
    addPlant(data: $data)
    {
      id,
      errors {
        key
        value
      }
    }
}
`;

const gqlAddPlantProps = {
  name: 'addPlant',
    options: {
    refetchQueries: [
      'getForPlants',
    ],
  },

};

[...]

const PlantsWithData =
compose(
graphql(gqlUpdatePlant, gqlUpdatePlantProps),
graphql(gqlGetPlants, gqlGetPlantsProps),
[...]
)(withRouter(PlantsContainer));

const PlantsWithDataAndDispatch = connect(
  null, // место для mapStateToProps
  null, // место для mapDispatchToProps
)(PlantsWithData);

export default PlantsWithDataAndDispatch



Для всплывающих уведомлений и сообщений об ошибках использован react-redux-toastr

Там, где требовались формы (создание контрольной точки, регистрация, логин, редактирование профиля и т.п.) использовался redux-form

Принцип работы с формами следующий:

components/ProfilePage.js:

import React from 'react';
import { Field, reduxForm } from 'redux-form';
import {  Button,  ControlLabel,  FormGroup,  Alert } from 'react-bootstrap';

const renderErrors = (errors) => (

  <Alert bsStyle="warning">
    {errors.map((error, index) => <span key={index}>{error.value}</span>)}
  </Alert>
);

class ProfilePage extends React.Component
{

  render() {
    const errors = this.props.errors <= 0 ? null : renderErrors(this.props.errors)
    const { handleSubmit } = this.props;

    return (
    <div className="container">

              <form onSubmit={handleSubmit} >

                <FormGroup controlId="firstname">
                  <ControlLabel>Имя:</ControlLabel>
                  <Field className="form-control" id="firstname" name="firstname" type="text" component="input"
                      placeholder="Ваше имя (например: 'Иван')"/>
                </FormGroup>
                 [...]
                {errors}

                <Button type="submit" bsStyle="primary">
                  Сохранить
                </Button>

              </form>

    </div>
    )
  }
}

ProfilePage = reduxForm({
  form: 'ProfileForm',
  enableReinitialize: true,
})(ProfilePage);

//        enableReinitialize: true, - если нужно менять значения в полях

export default ProfilePage;



containers/ProfileContainer.js:

import React from 'react';
import { withRouter } from 'react-router';
import { connect } from 'react-redux';
import gql from 'graphql-tag';
import { graphql, compose } from 'react-apollo';
import ProfilePage from '../components/ProfilePage';
import { toastr } from 'react-redux-toastr'
const jwtDecode = require('jwt-decode');

class ProfileContainer extends React.Component {

  constructor(props) {
    super(props);
    this.state = { errors: [] };
  }

  handleSubmit(data) {

    this.props.updateProfile({ variables: {
      data: {
        firstname: data.firstname,
        [...]
      },
      id: jwtDecode(localStorage.getItem('token')).id
    }})
      .then((response) => {

        this.setState({
          errors: []
        });

        if (response.data.updateProfile.errors.length <= 0) {
          toastr.success('*', 'Данные профиля сохранены', {showCloseButton: false})

        } else {
          this.setState({
            errors: response.data.updateProfile.errors
          });
        }

      })
      .catch((err) => {
        console.error(err);
          toastr.error('*', 'Ошибка при сохранении профиля', {showCloseButton: false})
      });
  }//handleSubmit


  render()
  {
      return <ProfilePage {...this.props} onSubmit={this.handleSubmit.bind(this)}  errors={this.state.errors} />;
  }

}//class

const gqlGetProfile = gql`
        query getProfile ($user_id: String!) {
          getProfile(user_id: $user_id) {
            profile {
              id,
              firstname,
              [...]
            },
            errors
            {
              key,
              value
            },
          }
        }
        `;

const gqlGetProfileProps =
{
  options: (ownProps) => ({
    variables: {
      user_id: jwtDecode(localStorage.getItem('token')).id,
    },
  }),

  props: ({ ownProps, data }) => {
    if (data.loading) {
      return {
        initialValues: { test: [] },
        errors: []
      };
    };
    return {
      initialValues: data.getProfile.profile,
      errors: data.getProfile.errors,
    };
  }
}

const gqlUpdateProfile = gql`
  mutation ($data: ProfileInput, $id : String) {

    updateProfile(data: $data, id : $id)
    {
      token,
      errors
      {
        key,
        value
      },
    }
  }
`;

const gqlUpdateProfileProps = {
  name: 'updateProfile',
    options: {
      refetchQueries: [
        'getProfile',
      ],
  },
};

const ProfileWithData =
compose(
graphql(gqlUpdateProfile, gqlUpdateProfileProps),
graphql(gqlGetProfile, gqlGetProfileProps),
)(withRouter(ProfileContainer));

const ProfileWithDataAndDispatch = connect(
  null,
  null
)(ProfileWithData);

export default ProfileWithDataAndDispatch



Мобильное приложение


Мобильное приложение реализовано для смартфонов Android 4.4+ (NFC), с использованием React-Native, Redux, SQLite.

Приложение позволяет:

1. Проводить обходы — т.е. фиксировать посещения заданных контрольных точек маршрута при помощи бесконтактных NFC меток и сохранять в базе связанные с ними параметры и замечания

2. Синхронизировать данные — получать с сервера маршруты, параметры контрольных точек, режим их обхода и отправлять на сервер данные по обходам и значениям, зафиксированным на контрольных точках



Как и в веб-клиенте в мобильном приложении имеются компоненты для страниц (экранов), однако вёрстка на Flex и из Android компонентов UI. Формы сделаны через redux-form и react-native-clean-form, но это довольно кривое решение, годное разве что для прототипа.

Также есть навигация между экранами, но вместо react-router веб клиента, здесь используется react-native-router-flux).

Есть запросы к БД, но они выполняются не через GraphQL к серверу, а прямо через вызовы actions, в которых через react-native-sqlite-storage происходит обращение к локальной SQLite БД (учитывая, что запросы асинхронные — используется redux-thunk)

GraphQL в мобильном приложении используется только для синхронизации данных с сервером и отсылки на сервер сообщений с id NFC метки и с ответами на отладочные запросы к БД (см. далее). Основная работа с приложением происходит в offline.

Выглядит компонент для экрана примерно так:

containers/CheckpointContainer.js

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import CheckpointPage  from '../components/CheckpointPage';
import { saveCheckpoint } from '../actions/CheckpointsActions';
import { Actions } from 'react-native-router-flux';
import { ToastAndroid } from 'react-native';

class CheckpointContainer extends Component {

handleSubmit = (data, dispatch) => {
  return new Promise((resolve) => {
    setTimeout(() => {
    
 this.props.saveCheckpoint(this.props.checkpoint_id,this.props.checkup_id,data.check_note,data.params).then((result)=>{

        ToastAndroid.showWithGravity('Данные сохранены', ToastAndroid.SHORT, ToastAndroid.BOTTOM);

        return true;
      }).catch((error)=>{

        ToastAndroid.showWithGravity('Не удалось сохранить данные', ToastAndroid.SHORT, ToastAndroid.BOTTOM);

      })

      resolve();
    }, 100)
  })
}

 render()
  {
      return <CheckpointPage  {...this.props} onSubmit={this.handleSubmit.bind(this)} />
  }

}//class

const mapStateToProps = (state) => {
    return {
        stateValues: state.checkpointData,
    };
};

const mapDispatchToProps = (dispatch) => {
  return bindActionCreators ({
    saveCheckpoint: (checkpoint_id, checkup_id, note,formData) => saveCheckpoint(checkpoint_id, checkup_id, note, formData),

  }, dispatch);
}

export default connect(mapStateToProps, mapDispatchToProps)(CheckpointContainer);


components/CheckpointPage.js

import React, { Component } from 'react'
import styles from '../styles';
import { reduxForm } from 'redux-form/immutable'

import {
  ActionsContainer,
  Button,
  FieldsContainer,
  Fieldset,
  Form,
} from 'react-native-clean-form'

import {
  Input,
} from 'react-native-clean-form/redux-form-immutable' 

import {
  View,
  Text,
  StyleSheet,
 } from 'react-native'

class CheckpointPage extends Component {

  render() {

    const { handleSubmit, submitting, onSubmit } = this.props

    return (
      <View style={{marginTop: 80, flex:1, flexDirection: 'column', }}>

      <Form>
        <FieldsContainer>
          <Fieldset label="ПАРАМЕТРЫ:" last>
              {
                this.props.stateValues.params.map((param, i) => {
                  [...]
                })
              }
          </Fieldset>
          <Fieldset label="ЗАМЕЧАНИЯ:" last>
            <Input name="check_note" label="Замечания:" placeholder="заметки, комментарии..." multiline={true} numberOfLines={2}  inlineLabel={false}   />
          </Fieldset>
        </FieldsContainer>
        <ActionsContainer>
          <Button icon="md-checkmark" iconPlacement="right" onPress={handleSubmit(onSubmit)} submitting={submitting}>Сохранить</Button>
        </ActionsContainer>
      </Form>
      </View>
    )
  }
}

export default reduxForm({
  form: 'Form',
  enableReinitialize: true,
  keepDirtyOnReinitialize: true,
})(CheckpointPage)



actions/CheckpointsActions.js

import * as types from '../actions/ActionTypes';
var uuid = require('react-native-uuid');

[...]

export function saveCheckpointSuccess(data) {
    return {
        type: 'SAVE_CHECKPOINT_SUCCESS',
        data
    };
}

function txUpdateChecks(result, check_id, note)
{
  console.log('CheckpointsActions/txUpdateChecks:', check_id, note);

  return new Promise(function(resolve, reject) {
    db.transaction((tx)=>{

      tx.executeSql(`UPDATE checks SET note = '${note}', dt_mod = '${nowSQL()}' WHERE checks.id = '${check_id}'`).then(([tx,results]) =>{

        result = results.rowsAffected;
        resolve(result);

      }).catch((error) => {

        reject(error);
      });//catch executeSql

    });//db.transaction

  })//return

}//txUpdateChecks()

[...]

export function saveCheckpoint(checkpoint_id, checkup_id, note, values)
{
  return (dispatch) => {
    let result;
    return txSelectChecks(checkup_id,checkpoint_id).then((check_id) => {

      if (!check_id)
      {
        txInsertChecks(result,checkpoint_id,checkup_id,note).then((check_id) => txInsertCheckValues(result,check_id,values))
        .then((result) => {

            dispatch(saveCheckpointSuccess(result));

        }).catch(error => {
            dispatch(dbFailed({method:'saveCheckpoint !check_id',error}));
        });

      }//if
      else
      {
        txUpdateChecks(result,check_id,note).then((result) => txUpdateCheckValues(result,check_id,values))
        .then((result) => {
            dispatch(saveCheckpointSuccess(result));

        }).catch(error => {
          dispatch(dbFailed({method:'txUpdateChecks/tx',error}));
        });

      }//else

    }).catch(error => {
      dispatch(dbFailed({method:'txSelectChecks/tx',error}));
    });

  }//return

}//saveCheckpoint

Переходы между экранами выполняются вызовами вида:
Actions.routesPage({shop_id:'6f6853d0-a642-11e7-83e7-792a5b00d12c_shop'});



Чтение NFC меток


Как уже было упомянуто, для идентификации контрольных точек предполагалось снабжать их NFC метками. Несмотря на то, что существуют различные типы таких меток, в данном случае дело упрощается тем, что для идентификации достаточно лишь читать их id, который (взависимости от типа метки) выглядит, например, как «F1:E8:A5:9E» или «04:2C:9F:12:85:20:83». Для целей тестирования при этом годятся любые карты — карты для проезда в метро, банковские и т.п.

Как это ни странно, среди нативных модулей React-Native для Android не оказалось ничего, связанного с NFC. Из сторонних тогда нашёлся только один модуль, который мне (и, похоже, другим людям тоже) заставить работать не удалось.
Нативные модули состоят из двух частей — одна на Javascript, вторая на Java. Я не пишу на Java но, посмотрев примеры и полистав документацию, выжал из себя пару десятков строк, которые читали NFC id карты и дёргали js обработчик. Не скрою — когда мой модуль заработал, я довольно сильно удивился.

Проблема со сторонними нативными модулями ещё и в том, что для их установки (по крайней мере, в некоторых случаях — мне попадались такие модули) необходимо руками лезть в несколько файлов типа android/app/src/main/java/[...]/MainApplication.java, android/settings.gradle, android/app/build.gradle. Потом это создаёт всякие неприятные ситуации с последующей установкой других пакетов. Не знаю, является ли это недоработкой таких как я авторов, или проблемой React Native. Так или иначе, модуль заработал. Далее, необходимо было прописать в AndroidManifest.xml:

[...]
    <uses-permission android:name="android.permission.NFC" />
[...]
    <intent-filter>
      <action android:name="android.nfc.action.TECH_DISCOVERED"/>
    </intent-filter>

      <meta-data android:name="android.nfc.action.TECH_DISCOVERED"
          android:resource="@xml/nfc_tech_filter" />
[...]

А в nfc_tech_filter.xml, соответственно, перечислить все возможные типы NFC меток:

<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
    <tech-list>
        <tech>android.nfc.tech.IsoDep</tech>
    </tech-list>
    <tech-list>
        <tech>android.nfc.tech.NfcA</tech>
    </tech-list>
    <tech-list>
        <tech>android.nfc.tech.NfcB</tech>
    </tech-list>
    <tech-list>
        <tech>android.nfc.tech.NfcF</tech>
    </tech-list>
    <tech-list>
        <tech>android.nfc.tech.NfcV</tech>
    </tech-list>
    <tech-list>
        <tech>android.nfc.tech.Ndef</tech>
    </tech-list>
    <tech-list>
        <tech>android.nfc.tech.NdefFormatable</tech>
    </tech-list>
    <tech-list>
        <tech>android.nfc.tech.MifareClassic</tech>
    </tech-list>
    <tech-list>
        <tech>android.nfc.tech.MifareUltralight</tech>
    </tech-list>
    <tech-list>
        <tech>android.nfc.tech.NfcBarcode</tech>
    </tech-list>
</resources>



В самом приложении всё уже получается просто:

const nfcListener = DeviceEventEmitter.addListener('NFCCardID', data =>
{
  console.log('NFC id', data.id)
  [...]
})


В эмуляторе, для имитации прикладывания смартфона к NFC метке, я не придумал ничего умнее, чем аналогичный обработчик для кнопки «Back». К слову — странно, что в Android эмуляторе не предусмотрено хотя бы симуляции NFC. Ведь в смартфонах NFC встречается уже далеко не первый год.

При создании контрольных точек (в веб приложении) необходимо поместить в базу их NFC id. Чтобы не устраивать сложных плясок с внешними USB NFC reader'ами или пересылкой id из сторонних приложений на смартфоне (типа такого), было реализовано чтение NFC id самим мобильным приложением с передачей его в веб клиент, через сервер.

Для этого использован механизм GraphQL subscriptions (подписки).
Мобильное приложение переводится в режим «Привязка точек» в котором, при прикладывании NFC метки и нажатии кнопки «Отправить» вызывается
метод sendTag({variables: { tag: {shop_id: 1, tag_id: data.tagId} } })
этот вызов запускает на сервере метод sendTag, внутри которого вызывается
pubsub.publish('tagSent', { tagSent: newMessage, shop_id: '1'});
Это, в конечном итоге, приводит к вызову в веб клиенте метода updateQuery, в который передаётся tag_id, после чего он оказывается в нужном поле веб-формы.

Ниже фрагменты исходников всех составных частей приложения, которые реализуют эту функциональность:

Мобильное приложение:

index.android.js

[...]
import { SubscriptionClient, addGraphQLSubscriptions } from 'subscriptions-transport-ws';


wsClient = new SubscriptionClient(`ws://10.0.2.2:3002/subscriptions`, {
  reconnect: true,
  timeout: 10000,
} )

const networkInterfaceWithSubscriptions = addGraphQLSubscriptions(
  networkInterface,
  wsClient,
);

function dataIdFromObject (result) {
  if (result.__typename) {
    if (result.id !== undefined) {
      return `${result.__typename}:${result.id}`;
    }
  }
  return null;
}

const client = new ApolloClient({
  networkInterface: networkInterfaceWithSubscriptions,
  customResolvers: {
    Query: {
      channel: (_, args) => {
        return toIdValue(dataIdFromObject({ __typename: 'Channel', id: 1 }))
      },
    },
  },
  dataIdFromObject,
});

[...]




components/CheckpointAttachPage.js

[...]

<Button title='Отправить номер метки' icon={{name:'tap-and-play'}}  buttonStyle={styles.button} onPress={ () => { this.props.sendTag({variables: { tag: {shop_id : 1, tag_id : data.tagId} } }).then(res => {
                ToastAndroid.showWithGravity(`Номер метки отправлен`, ToastAndroid.SHORT, ToastAndroid.BOTTOM);
              } ).catch(error =>{
                ToastAndroid.showWithGravity(`Ошибка при отправке (сеть?)`, ToastAndroid.SHORT, ToastAndroid.BOTTOM);
              }) ; } } />
[...]              




containers/CheckpointAttachContainer.js

[...]

const gqlSendTag = gql`
  mutation sendTag($tag: TagInput!) {
    sendTag(tag: $tag) {
      tag_id
      shop_id
      title
    }
  }
`;


const gqlSendTagProps = {
  name: 'sendTag',
    options: {
  },

};

[...]



Сервер:

server.js

import express from 'express';
import { createServer } from 'http';
const server = express();

[...]

const ws = createServer(server);

server.use(PATH.GIQL, graphiqlExpress({
  endpointURL: PATH.GQL,
  subscriptionsEndpoint: `ws://${GQLSERVERLOCAL.HOST}:${GQLSERVERLOCAL.PORT}/subscriptions`
}));

ws.listen(GQLSERVERLOCAL.PORT, () => {

  new SubscriptionServer({
    execute,
    subscribe,
    schema
  }, {
    server: ws,
    path: PATH.SUBS,
  });
});

[...]



schema.js

type Mutation {
  [...]
  sendTag(tag: TagInput!): Tag

}

[...]

input TagInput{
  shop_id: String
  tag_id: String
  title: String
}

type Tag {
  shop_id: String
  tag_id: String
  title: String
}

type Subscription {
  tagSent(shop_id: String): Tag
}

[...]



resolvers.js

import { PubSub } from 'graphql-subscriptions';
import { withFilter } from 'graphql-subscriptions';

const pubsub = new PubSub();
[...]
export const resolvers = {

  Mutation: {

    sendTag: (root, { tag }) => {

      const newMessage = { title: tag.title, shop_id: tag.shop_id, tag_id: tag.tag_id };
      pubsub.publish('tagSent', { tagSent: newMessage, shop_id: '1'}); 

      return newMessage;
    },

  [...]

  },

  Subscription: {

    tagSent: {
      subscribe: withFilter(() => pubsub.asyncIterator('tagSent'), (payload, variables) => {
        return payload.shop_id === variables.shop_id; 
      }),
    },

  [...]

  },

}


Веб клиент:

index.js

[...]

import { SubscriptionClient, addGraphQLSubscriptions } from 'subscriptions-transport-ws';

wsClient = new SubscriptionClient(`ws://localhost:3002/subscriptions`, {
  reconnect: true,
})


const networkInterfaceWithSubscriptions = addGraphQLSubscriptions(
  networkInterface,
  wsClient,
);

function dataIdFromObject (result) {
  if (result.__typename) {
    if (result.id !== undefined) {
      return `${result.__typename}:${result.id}`;
    }
  }
  return null;
}

const client = new ApolloClient({
  networkInterface: networkInterfaceWithSubscriptions,
  customResolvers: {
    Query: {
      channel: (_, args) => {
        return toIdValue(dataIdFromObject({ __typename: 'Channel', id: 1 }))
      },
    },
  },
  dataIdFromObject,
});

[...]




components/AddCheckpointPage.js

import { Field } from 'redux-form';
[...]

<Field className="form-control" id="tag_id" name="tag_id" type="text" component="input" placeholder="В приложении на телефоне выберите 'привязка метки'"/>

[...]



containers/AddCheckpointContainer.js

[...]

import { change as changeFieldValue } from 'redux-form';

const tagSubscription = gql`
  subscription tagSent($shop_id: String) {

    tagSent(shop_id: $shop_id) {
      tag_id
      shop_id
      title
    },

  }
`

class AddCheckpointContainer extends React.Component {

  componentWillMount() {

    this.props.data.subscribeToMore({
      document: tagSubscription,
      variables: {
        shop_id: 1,
      },
      onError: (err) => console.error('subscribeToMore ERROR:',err),
      updateQuery: (prev, {subscriptionData}) => {
        console.log('updateQuery:',subscriptionData)
        this.props.changeFieldValue("addCheckpointForm", "tag_id", subscriptionData.data.tagSent.tag_id);

      }
    });
  }//componentWillMount()

  [...]

}


В завершении разбора — небольшое видео, демонстрирующее работу мобильного приложения и веб клиента:


Средства разработки и отладка


Писалось и отлаживалось всё под Windows 7, но для демонстрации заказчику переносилось на linux сервер. Что касается среды разработки — я любитель Sublime. Правда, должен заметить, что с подсветкой синтаксиса gql запросов внутри js кода регулярно случаются глюки. Уж больно там это всё нетрадиционно сочетается. Пару раз возникала мысль попробовать VSCode (но пока держусь).



Установка nodejs под Win7 никаких особых проблем не вызвала, а вот с установкой и настройкой Android SDK так, чтобы всё потом вместе зашевелилось — пришлось хорошо повозиться.

Что касается средств отладки, то для веб клиента это обычный Chrome Dev tools с расширениями Apollo Client Developer Tools и Redux DevTools. Для мобильного приложения — React Native Debugger (практически тоже самое, только реализованное отдельно на Electron).
Отмечу, что для локальной отладки с эмулятором, вместо localhost нужно использовать адрес 10.0.2.2

Кроме того, для удобства отладки в самих клиентах и сервере я реализовал возможность посылать SQL запросы к SQLite базе в смартфоне — как непосредственно со смартфона (из меню «Отладка»), так и удалённо — из веб приложения.

Во втором случае это осуществляется через механизм GraphQL подписки, через веб-сокеты. Веб приложение вызывает sendDebugRequest() на сервере, который формирует сообщение newDebugRequestCreated, на которое подписано мобильное приложение. В мобильном приложении при получении этого сообщения выполняется execSql(). Результат запроса отправляется на сервер вызовом sendDebugResponse(), который в свою очередь формирует сообщение newDebugResponseCreated, на которое подписано веб приложение. Фактически, это подобие чата, просто из мобильного приложения «отвечает» не человек, а БД.

Для просмотра и редактирования файлов SQLite под Win7 очень хорошо мне зашёл (после перебора разных вариантов) — SQLiteStudio.

Полезная мелочь — чтобы на сервере console.log() красиво и полностью выводил объекты, можно пользоваться util.inspect()

const util = require('util')
util.inspect.defaultOptions.showHidden = false
util.inspect.defaultOptions.depth = null
util.inspect.defaultOptions.maxArrayLength = 1000
util.inspect.defaultOptions.colors = true
[...]
console.log("data: ", util.inspect(data))

Также в связи с сервером — всё время забываю, что если в переменной промис то, чтобы увидеть результат в логе сервера не в виде "[Promise]", обязательно нужен then.

variable.then(function(result) {    console.log('Эврика!',result)  })

Авторизация/аутентификация


При выполнении регистрации, веб-клиент вызывает серверный метод signUp, где из введённого пароля при помощи bcrypt.hash генерируется хэш, который вместе с email (логином) помещается в БД.

При логине через веб-клиент вызывает серверный метод signIn, в котором проверяется наличие пользователя с данным email и корректность пароля (через bcrypt.compare). После этого в signIn вызовом jwt.sign генерирует токен, который возвращается веб-клиенту.

Веб-клиент сохраняет токен в localStorage и далее достаёт его оттуда и предъявляет серверу в http заголовке при каждом GraphQL запросе (для чего в network.use applyMiddleware указывается req.options.headers.authorization = `Bearer ${token}`)

Сервер, получая GraphQL запросы, в server.js каждый раз берёт полученный с ними токен из http заголовка Authorization, проверяет и дешифрует его при помощи express-jwt, получая user.id, по которому из БД извлекаются необходимые данные о правах пользователя, затем передаваемые в context, доступный внутри всех вызываемых клиентом серверных методов (в connectors.js). При этом важно, что jwt() вызывается с параметром «credentialsRequired: false», что позволяет игнорировать ошибки аутентификации и обрабатывать их самим (это необходимо для работоспособности компонентов SignIn и SignUp в незалогиненном состоянии).

Мобильное приложение, в отличии от веб клиента, должно работать без доступа к сети. Поэтому, при синхронизации БД с сервера, среди прочего передаются данные (email, хэш пароля) пользователя. При логине в мобильное приложение запроса к серверу не осуществляется — проверяется существование пользователя и корректность пароля либо nfc_id (взависимости от того, вводится логин вручную или прикладывается NFC карта) по копии в локальной БД, вызовом bcrypt.compare для React Native.

При соединении с сервером предъявляется идентификатор устройства и другие данные, на основании которых сервер разрешает доступ.

Проблемы


Как уже было упомянуто в начале статьи, это моё первое знакомство с перечисленными технологиями. И вот каковы мои впечатления:

  1. Зоопарк пакетов, связанных сложными зависимостями, причём каждый тянет за собой десяток других. Обновление одних нередко всё ломает — причём, иногда это происходит без каких-либо сообщений об ошибках и обнаружить, в чём проблема — весьма сложно. Особенно неприятны ситуации, когда какой-то пакет необходимо обновить, т.к. в нём исправлен критичный баг, но обновлять его нельзя, потому что тогда перестанет работать третий. Другая ситуация — когда обновить необходимо, но в пакете изменилась какая-то логика и требуется переписать, скажем, 10-20% моего приложения. Получается дилемма — или ты сидишь на старых версиях тщательно подобранных друг к другу пакетов и боишься чихнуть, или постоянно всё обновляешь и занят переделыванием своего кода под всё новые и новые изменения в них.

    Чисто эмоциональный аспект: ситуация, когда node_modules/ для проекта занимает порядка 200mb и содержит десятки тысяч (!) файлов — с непривычки шокирует. Да, понятно, что в окончательный код попадает не так уж много (десятки файлов и единицы мегабайт), но тем не менее.
  2. Настройка webpack/babel — очень нетривиальное занятие. Я так понимаю, люди не так часто руками пишут все конфиги с нуля — наверное обычно берут какой-нибудь готовый «суповой набор», типа create-react-app и create-react-native-app. По этому пути пошёл и я, но в итоге всё равно возникли ситуации, когда пришлось лезть в конфиги и разбираться. Кроме того, есть вещи, которые такие наборы принудительно навязывают (к примеру, неотключаемый eslint или внезапный service-worker.js, используемый для кэширования)
  3. При сборке (в моём случае — под Win7) в разных ситуациях регулярно возникают сообщения об ошибках, которые на самом деле не указывают на проблемы с приложением и зачастую эти сообщения лечатся простым перезапуском. Причём, вчера ошибки не возникали, сегодня появились, завтра могут исчезнуть опять и потом вновь появиться. К примеру, в данный момент при запуске react-native run-android --variant=release у меня последовательно появляются несколько разных ошибок (связанных, по всей видимости, с невозможностью получить доступ к файлам в assets/) и проблема решается трёх-пятикратным повторением этой операции (если не помогает, ещё приходится дополнительно почистить \android\.gradle ).

    Когда начинаешь гуглить обо всём этом, выясняется, что через такое проходят сотни человек, причём все решают это разными (часто — магическими) способами, которые у каждого свои и редко помогают другим страдальцам. Постепенно к этому привыкаешь и уже интуитивно понимаешь, что конкретно надо попробовать предпринять в первую очередь.

    Стоит также упомянуть о плясках с бубном для получения подписанного для релиза apk — с ползанием по конфигам gradle и выяснением что, оказывается, где-то там надо было буквы строчные вместо прописных, лишний перевод строки, еще один конфиг не в том директории где он был (внезапно!) и т.п. ньюансы. Это тоже одна из популярных проблем, судя по огромному количеству вопросов, советов и магических решений.

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

Что бы я сделал иначе, если бы начинал эту работу сейчас?


  1. Взял бы более свежую комбинацию пакетов:
  2. React Router 4 (он сильно изменился по сравнению с 3)
  3. Новую четвертую версию react-native-router-flux (аналогично)
  4. Вместо своего NFC велосипеда посмотрел бы, что появилось в этой области. Например это.
  5. Обновил версию Apollo GraphQL и попробовал бы весь обмен в GraphQL сделать через сокеты (сейчас через сокеты только сообщения).
  6. Вместо react-bootstrap-table использовал бы react-bootstrap-table2 или что-то другое (может быть react-table).
  7. Возможно, отказался бы от Bootstrap. С другой стороны, Material UI выглядит на десктопе не очень адекватно в плане использования пространства экрана. По существу, итоговый выбор еще зависит и от остальных используемых пакетов.
  8. Для работы с БД возможно стоит использовать прокладку в виде Sequilize. Кроме того, по-прежнему не уверен, следовало ли в мобильном приложении возиться с SQLite и не стоило ли всё, что нужно (данные с сервера, результаты обходов) держать вместо этого в redux store.
    Вообще, понимание, что нужно хранить в store, для чего достаточно локального state, а что помещать в БД — это всё, конечно, приобретается с опытом.
  9. Использовать в React Native другой drawer (тот, что был доступен на момент начала работ был не целиком нативным и из-за этого заметно тормозил, а при включенной отладке — вообще еле двигался). По идее, сейчас этот вопрос уже решён в react-navigation, которая используется в новом react-native-router-flux.
  10. Формы, на мой взгляд — настоящая проблема. Я использовал redux-form, но даже в веб клиенте простые вещи решаются достаточно сложно и запутанно. А с React Native она вообще не очень работает. Несколько раз я думал избавиться от redux form и сделать всё самому и в react и в react native. Но лень каждый раз побеждала.

В заключение, порекомендую две статьи, которые однозначно стоит прочесть по теме (таких статей, увы, не так уж много, особенно в связи с быстрым устареванием материала): Создание «клона WhatsApp» (на React-Native, NodeJS, GraphQL) и лучшая, на мой взгляд, статья про GraphQL: Dive intro GraphQL.
Опубликовано: Пётр Соболев

Случайная заметка

9236 дней назад, 02:3918 июля 1999 Не дождался пока фильм The Matrix пойдет в кино, посмотрел на кассете.. Коротко поделюсь впечатлениями (кто еще собирается смотреть - не читайте :). Смотреть интересно, хотя сценарий не на 5. Общая идея: действие происходит в будущем, где все живут не на самом деле. Компьютер(ы) имитируют для людей реальность, в то время как в ...далее

Избранное

2735 дней назад, 01:575 мая 2017 Часть 1: От четырёх до восьми Я люблю читать воспоминания людей, заставших первые шаги вычислительной техники в их стране. В них всегда есть какая-то романтика, причём какого она рода — сильно зависит от того, с каких компьютеров люди начали. Обычно это определяется обстоятельствами — местом работы, учёбы, а иногда и вовсе — ...далее

2247 дней назад, 20:305 сентября 2018 "Finally, we come to the instruction we've all been waiting for – SEX!" / из статьи про микропроцессор CDP1802 / В начале 1970-х в США были весьма популярны простые электронные игры типа Pong (в СССР их аналоги появились в продаже через 5-10 лет). Как правило, такие игры не имели микропроцессора и памяти в современном понимании этих слов, а строились на жёсткой ...далее