В одиночку создайте чат, поддержите три терминала Web/Android/iOS

внешний интерфейс iOS React.js Redux

Оригинальный адрес:Github.com/yunxin630/no...

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

Адрес источника:GitHub.com/Yunxin630/Пост…
Интернет-адрес:fiora.suisuijiang.com/

предисловие

Проект стартовал в конце 2015 года, когда я впервые начал изучать JavaScript.В то время я просто хотел заняться практическим проектом.Позже, с углубленным изучением фронтенда, я обновлял стек технологий, который сейчас подвергся рефакторингу, пятое издание

Спасибоnode.jsа такжеreact-nativeПоявление jser заставляет щупальца jser достигать серверной стороны и стороны приложения.Серверная сторона этого проекта основана на технологии node.js, использует фреймворк koa, а все данные хранятся в mongodb.Клиентская сторона использует react framework и использует redux и immutable.js для управления статусом, я разработал набор простых стилей пользовательского интерфейса, сторона APP разработана на основе react-native и expo, проект развернут на моей нищей версии Alibaba Cloud ECS, и студенческий компьютер настроен на одноядерную память 1G

Серверная архитектура

Сервер отвечает за две вещи:

  1. Предоставляет интерфейс на основе WebSocket
  2. Подавать ответ index.html

сервер используетkoa-socketЭтот пакет интегрирует socket.io и реализует механизм промежуточного программного обеспечения сокета.Сервер реализует набор маршрутизации интерфейса на основе механизма промежуточного программного обеспечения.

Каждый интерфейс является асинхронной функцией, имя функции — это имя интерфейса, а также имя события сокета.

async login(ctx) {
    return 'login success'
}

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

function noop() {}

/**
 * 路由处理
 * @param {IO} io koa socket io实例
 * @param {Object} routes 路由
 */
module.exports = function (io, _io, routes) {
    Object.keys(routes).forEach((route) => {
        io.on(route, noop); // 注册事件
    });

    return async (ctx) => {
        // 判断路由是否存在
        if (routes[ctx.event]) {
            const { event, data, socket } = ctx;
            // 执行路由并获取返回数据
            ctx.res = await routes[ctx.event]({
                event, // 事件名
                data, // 请求数据
                socket, // 用户socket实例
                io, // koa-socket实例
                _io, // socket.io实例
            });
        }
    };
};

Еще одно важное промежуточное программное обеспечениеcatchError, который отвечает за перехват глобальных исключений и широко используется в бизнес-процессахassertСудя по бизнес-логике, если условия не соблюдены, процесс будет прерван и будет возвращено сообщение об ошибке, catchError перехватит исключение бизнес-логики, вынесет сообщение об ошибке и вернет его клиенту

const assert = require('assert');

/**
 * 全局异常捕获
 */
module.exports = function () {
    return async (ctx, next) => {
        try {
            await next();
        } catch (err) {
            if (err instanceof assert.AssertionError) {
                ctx.res = err.message;
                return;
            }
            ctx.res = `Server Error: ${err.message}`;
            console.error('Unhandled Error\n', err);
        }
    };
};

Это основная логика сервера, а бизнес-логика состоит из интерфейсов, определенных в этой архитектуре.

Кроме того, сервер также отвечает за предоставление ответа index.html, который является домашней страницей клиента.Другие ресурсы клиента размещаются в CDN, что может снизить нагрузку на полосу пропускания сервера, но index.html нельзя использовать сильное кэширование, потому что это сделает клиентские обновления неконтролируемыми, поэтому index.html размещается на сервере

Клиентская архитектура

Использование клиентаsocket.io-clientПодключитесь к серверу. После успешного подключения запросите у интерфейса попытку входа в систему. Если у localStorage нет токена или интерфейс возвращает токен с истекшим сроком действия, он войдет в систему как посетитель. Если вход выполнен успешно, он вернет информацию о пользователе и список групп и друзей, а затем запросит каждую группу и друга.

Клиенту необходимо прослушать для подключения/отключения/сообщения три сообщения

  1. connect: соединение сокета успешно
  2. disconnectсоединение сокета отключено
  3. messageполучено новое сообщение

Использование клиентаreduxДля управления данными данные, которые должны быть разделены компонентами, помещаются в редукцию, и только данные, используемые сами по себе, помещаются в состояние компонента.Структура данных редукса, хранящаяся на стороне клиента, выглядит следующим образом:

客户端store结构

  • пользовательская информация пользователя
    • _id идентификатор пользователя
    • имя пользователя
    • список контактов linkmans, включая группы, друзей и специальные разговоры
    • isAdmin — администратор
  • сфокусировать текущий идентификатор контакта, который является целью в разговоре
  • подключить статус подключения
  • Пользовательский интерфейс клиента, связанный с пользовательским интерфейсом, и переключатели функций

Поток данных клиента, в основном, две линии

  1. Действие пользователя => интерфейс запроса => возвращаемые данные => обновить избыточность => просмотреть повторный рендеринг
  2. Прослушивание новых сообщений => обработка данных => обновление редукции => просмотр повторного рендеринга

Пользовательская система

Определение пользовательской схемы:

const UserSchema = new Schema({
    createTime: { type: Date, default: Date.now },
    lastLoginTime: { type: Date, default: Date.now },

    username: {
        type: String,
        trim: true,
        unique: true,
        match: /^([0-9a-zA-Z]{1,2}|[\u4e00-\u9eff]){1,8}$/,
        index: true,
    },
    salt: String,
    password: String,
    avatar: {
        type: String,
    },
});
  • createTime: время создания
  • lastLoginTime: время последнего входа в систему, используемое для очистки учетной записи зомби.
  • username: псевдоним пользователя, который также является учетной записью
  • salt: зашифрованная соль
  • password: пользовательский пароль
  • avatar: URL-адрес аватара пользователя

Регистрация пользователя

Требуется регистрация Интерфейсusername / passwordДва параметра, сначала сделайте это

const {
    username, password
} = ctx.data;
assert(username, '用户名不能为空');
assert(password, '密码不能为空');

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

const user = await User.findOne({ username });
assert(!user, '该用户名已存在');
const defaultGroup = await Group.findOne({ isDefault: true });
assert(defaultGroup, '默认群组不存在');

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

const salt = await bcrypt.genSalt$(saltRounds);
const hash = await bcrypt.hash$(password, salt);

Дайте пользователям случайный аватар по умолчанию, все они милые девушки^_^, сохранить информацию о пользователе в базу данных

let newUser = null;
try {
    newUser = await User.create({
        username,
        salt,
        password: hash,
        avatar: getRandomAvatar(),
    });
} catch (err) {
    if (err.name === 'ValidationError') {
        return '用户名包含不支持的字符或者长度超过限制';
    }
    throw err;
}

Добавьте пользователя в группу по умолчанию, затем сгенерируйте токен пользователя Токен — это учетная запись, используемая для входа в систему без пароля. Он хранится в локальном хранилище клиента. Токен содержит три данных: идентификатор пользователя, срок действия и информацию о клиенте. Идентификатор пользователя и срок действия легко понять. Используется информация о клиенте. для предотвращения кражи токенов. Раньше я также пытался проверить согласованность IP-адреса клиента, но IP-адрес может часто меняться, поэтому каждый раз, когда пользователь автоматически входит в систему, он считается украденным...

defaultGroup.members.push(newUser);
await defaultGroup.save();

const token = generateToken(newUser._id, environment);

Свяжите идентификатор пользователя с текущим подключением к сокету, серверctx.socket.userНе определено, чтобы судить о статусе входа в систему Обновите текущую информацию о подключении к сокету в таблице сокетов, а затем получите онлайн-пользователя, который получит данные таблицы сокетов.

ctx.socket.user = newUser._id;
await Socket.update({ id: ctx.socket.id }, {
    user: newUser._id,
    os, // 客户端系统
    browser, // 客户端浏览器
    environment, // 客户端环境信息
});

Наконец, верните данные клиенту

return {
    _id: newUser._id,
    avatar: newUser.avatar,
    username: newUser.username,
    groups: [{
        _id: defaultGroup._id,
        name: defaultGroup.name,
        avatar: defaultGroup.avatar,
        creator: defaultGroup.creator,
        createTime: defaultGroup.createTime,
        messages: [],
    }],
    friends: [],
    token,
}

Логин пользователя

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

Возможны три ситуации входа в систему:

  • Гостевой вход
  • вход по токену
  • логин/пароль логин

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

Чаще всего используется вход с токеном, клиент сначала получает токен из localStorage, и, если токен существует, он будет использовать токен для входа в систему.
Сначала декодируйте токен и извлеките данные полезной нагрузки, чтобы определить, истек ли срок действия токена и совпадает ли информация о клиенте.

let payload = null;
try {
    payload = jwt.decode(token, config.jwtSecret);
} catch (err) {
    return '非法token';
}

assert(Date.now() < payload.expires, 'token已过期');
assert.equal(environment, payload.environment, '非法登录');

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

const user = await User.findOne({ _id: payload.user }, { _id: 1, avatar: 1, username: 1 });
assert(user, '用户不存在');

user.lastLoginTime = Date.now();
await user.save();

const groups = await Group.find({ members: user }, { _id: 1, name: 1, avatar: 1, creator: 1, createTime: 1 });
groups.forEach((group) => {
    ctx.socket.socket.join(group._id);
    return group;
});

const friends = await Friend
    .find({ from: user._id })
    .populate('to', { avatar: 1, username: 1 });

Обновите информацию о сокете, как при регистрации

ctx.socket.user = user._id;
await Socket.update({ id: ctx.socket.id }, {
    user: user._id,
    os,
    browser,
    environment,
});

Наконец верните данные

Логика имени пользователя/пароля и входа по токену отличается только в начале, нет шага декодирования данных проверки токена. Сначала убедитесь, что имя пользователя существует, а затем убедитесь, что пароль совпадает.

const user = await User.findOne({ username });
assert(user, '该用户不存在');

const isPasswordCorrect = bcrypt.compareSync(password, user.password);
assert(isPasswordCorrect, '密码错误');

Следующая логика соответствует входу в систему с токеном

система сообщений

отправлять сообщения

Интерфейс sendMessage имеет три параметра:

  • to: отправленный объект, группа или пользователь
  • type: тип сообщения
  • content: содержание сообщения

Поскольку групповой чат и приватный чат используют этот интерфейс, сначала вам нужно определить, является ли это групповым чатом или приватным чатом, получить идентификатор группы или идентификатор пользователя и различать групповой чат и приватный чат с помощью параметра to.
В групповом чате to — это соответствующий идентификатор группы, а затем получить информацию о группе. В приватном чате to является результатом объединения идентификаторов отправителя и получателя. Удалите идентификатор отправителя, чтобы получить идентификатор получателя, а затем получите информацию о получателе.

let groupId = '';
let userId = '';
if (isValid(to)) {
    const group = await Group.findOne({ _id: to });
    assert(group, '群组不存在');
} else {
    userId = to.replace(ctx.socket.user, '');
    assert(isValid(userId), '无效的用户ID');
    const user = await User.findOne({ _id: userId });
    assert(user, '用户不存在');
}

Некоторые типы сообщений необходимо обрабатывать.Текстовое сообщение оценивает длину и выполняет обработку xss.Сообщение-приглашение оценивает, существует ли приглашенная группа, а затем сохраняет приглашающего, идентификатор группы, имя группы и другую информацию в теле сообщения.

let messageContent = content;
if (type === 'text') {
    assert(messageContent.length <= 2048, '消息长度过长');
    messageContent = xss(content);
} else if (type === 'invite') {
    const group = await Group.findOne({ name: content });
    assert(group, '目标群组不存在');

    const user = await User.findOne({ _id: ctx.socket.user });
    messageContent = JSON.stringify({
        inviter: user.username,
        groupId: group._id,
        groupName: group.name,
    });
}

Хранить новые сообщения в базе данных

let message;
try {
    message = await Message.create({
        from: ctx.socket.user,
        to,
        type,
        content: messageContent,
    });
} catch (err) {
    throw err;
}

Затем создайте данные сообщения, которые не содержат конфиденциальной информации. Данные включают идентификатор отправителя, имя пользователя и аватар. Имя пользователя и аватар являются относительно избыточными данными. В будущем они будут оптимизированы для передачи только одного идентификатора, а клиент ведет юзер.информацию, сопоставляет логин и аватарку по id, что может сэкономить много трафика Если это сообщение группового чата, просто отправьте сообщение в соответствующую группу напрямую. Сообщения в приватном чате немного сложнее, потому что fiora допускает множественный вход в систему, и его нужно сначала отправить во все онлайн-сокеты получателя, а затем — на остальные его собственные онлайн-сокеты.

const user = await User.findOne({ _id: ctx.socket.user }, { username: 1, avatar: 1 });
const messageData = {
    _id: message._id,
    createTime: message.createTime,
    from: user.toObject(),
    to,
    type,
    content: messageContent,
};

if (groupId) {
    ctx.socket.socket.to(groupId).emit('message', messageData);
} else {
    const sockets = await Socket.find({ user: userId });
    sockets.forEach((socket) => {
        ctx._io.to(socket.id).emit('message', messageData);
    });
    const selfSockets = await Socket.find({ user: ctx.socket.user });
    selfSockets.forEach((socket) => {
        if (socket.id !== ctx.socket.id) {
            ctx._io.to(socket.id).emit('message', messageData);
        }
    });
}

Наконец, данные сообщения возвращаются клиенту, указывая на то, что сообщение отправлено успешно.Чтобы оптимизировать взаимодействие с пользователем, клиент будет немедленно отображать новую информацию на странице при отправке сообщения и запрашивать интерфейс для отправки сообщения. , Если сообщение не может быть отправлено, сообщение будет удалено.

Получить исторические новости

Интерфейс getLinkmanHistoryMessages имеет два параметра:

  • linkmanId: Идентификатор контакта, группа или два объединенных идентификатора пользователя
  • existCount: количество существующих сообщений

Подробная логика относительно проста: найдите существующее количество сообщений в порядке, обратном времени создания + количество сообщений, полученных каждый раз, затем удалите существующее количество сообщений и переверните их, то есть новое сообщение, отсортированное по времени.

const messages = await Message
    .find(
        { to: linkmanId },
        { type: 1, content: 1, from: 1, createTime: 1 },
        { sort: { createTime: -1 }, limit: EachFetchMessagesCount + existCount },
    )
    .populate('from', { username: 1, avatar: 1 });
const result = messages.slice(existCount).reverse();

вернуться к клиенту

Получать push-сообщения

Клиенты подписываются на событие сообщения, чтобы получать новые сообщения.socket.on('message')

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

const state = store.getState();
const isSelfMessage = message.from._id === state.getIn(['user', '_id']);
const linkman = state.getIn(['user', 'linkmans']).find(l => l.get('_id') === message.to);
let title = '';
if (linkman) {
    action.addLinkmanMessage(message.to, message);
    if (linkman.get('type') === 'group') {
        title = `${message.from.username} 在 ${linkman.get('name')} 对大家说:`;
    } else {
        title = `${message.from.username} 对你说:`;
    }
} else {
    // 联系人不存在并且是自己发的消息, 不创建新联系人
    if (isSelfMessage) {
        return;
    }
    const newLinkman = {
        _id: getFriendId(
            state.getIn(['user', '_id']),
            message.from._id,
        ),
        type: 'temporary',
        createTime: Date.now(),
        avatar: message.from.avatar,
        name: message.from.username,
        messages: [],
        unread: 1,
    };
    action.addLinkman(newLinkman);
    title = `${message.from.username} 对你说:`;

    fetch('getLinkmanHistoryMessages', { linkmanId: newLinkman._id }).then(([err, res]) => {
        if (!err) {
            action.addLinkmanMessages(newLinkman._id, res);
        }
    });
}

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

if (windowStatus === 'blur' && state.getIn(['ui', 'notificationSwitch'])) {
    notification(
        title,
        message.from.avatar,
        message.type === 'text' ? message.content : `[${message.type}]`,
        Math.random(),
    );
}

Если переключатель звука включен, издайте звуковой сигнал нового сообщения

if (state.getIn(['ui', 'soundSwitch'])) {
    const soundType = state.getIn(['ui', 'sound']);
    sound(soundType);
}

Если переключатель языка широковещания включен и это текстовое сообщение, отфильтруйте URL-адрес и # в сообщении, исключите сообщения длиной более 200 и затем поместите их в очередь чтения сообщений.

if (message.type === 'text' && state.getIn(['ui', 'voiceSwitch'])) {
    const text = message.content
        .replace(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g, '')
        .replace(/#/g, '');
    // The maximum number of words is 200
    if (text.length > 200) {
        return;
    }

    const from = linkman && linkman.get('type') === 'group' ?
        `${message.from.username}在${linkman.get('name')}说`
        :
        `${message.from.username}对你说`;
    if (text) {
        voice.push(from !== prevFrom ? from + text : text, message.from.username);
    }
    prevFrom = from;
}

больше промежуточного ПО

Ограничить незарегистрированные запросы

Большинство интерфейсов доступны только вошедшим в систему пользователям. Если интерфейс требует входа в систему, а в сокет-соединении нет информации о пользователе, он вернет ошибку «не вошел в систему».

/**
 * 拦截未登录请求
 */
module.exports = function () {
    const noUseLoginEvent = {
        register: true,
        login: true,
        loginByToken: true,
        guest: true,
        getDefalutGroupHistoryMessages: true,
        getDefaultGroupOnlineMembers: true,
    };
    return async (ctx, next) => {
        if (!noUseLoginEvent[ctx.event] && !ctx.socket.user) {
            ctx.res = '请登录后再试';
            return;
        }
        await next();
    };
};

Ограничить частоту звонков

Чтобы предотвратить ситуацию очистки интерфейса и снизить нагрузку на сервер, одно и то же подключение к сокету ограничено максимальным количеством запросов интерфейса 30 в минуту.

const MaxCallPerMinutes = 30;
/**
 * Limiting the frequency of interface calls
 */
module.exports = function () {
    let callTimes = {};
    setInterval(() => callTimes = {}, 60000); // Emptying every 60 seconds

    return async (ctx, next) => {
        const socketId = ctx.socket.id;
        const count = callTimes[socketId] || 0;
        if (count >= MaxCallPerMinutes) {
            return ctx.res = '接口调用频繁';
        }
        callTimes[socketId] = count + 1;
        await next();
    };
};

черный дом

Учетная запись администратора может добавлять пользователей в черный дом.Пользователи, добавленные в черный дом, не могут запрашивать какой-либо интерфейс, и бан будет автоматически снят через 10 минут.

/**
 * Refusing to seal user requests
 */
module.exports = function () {
    return async (ctx, next) => {
        const sealList = global.mdb.get('sealList');
        if (ctx.socket.user && sealList.has(ctx.socket.user.toString())) {
            return ctx.res = '你已经被关进小黑屋中, 请反思后再试';
        }

        await next();
    };
};

Другие интересные вещи

выражение

Смайлик представляет собой изображение спрайта. Щелчок по смайлику вставит формат в поле ввода.#(xx)текст, например#(滑稽), При отображении сообщения замените этот текст регулярным выражением на<img>, и вычислите положение выражения на карте спрайтов, а затем отобразите его на страницеЕсли src не задан, будет отображаться рамка, а для src необходимо установить прозрачное изображение.

function convertExpression(txt) {
    return txt.replace(
        /#\(([\u4e00-\u9fa5a-z]+)\)/g,
        (r, e) => {
            const index = expressions.default.indexOf(e);
            if (index !== -1) {
                return `<img class="expression-baidu" src="${transparentImage}" style="background-position: left ${-30 * index}px;" onerror="this.style.display='none'" alt="${r}">`;
            }
            return r;
        },
    );
}

Поиск эмодзи

взбиратьсяwww.doutula.comрезультаты поиска на

const res = await axios.get(`https://www.doutula.com/search?keyword=${encodeURIComponent(keywords)}`);
assert(res.status === 200, '搜索表情包失败, 请重试');

const images = res.data.match(/data-original="[^ "]+"/g) || [];
return images.map(i => i.substring(15, i.length - 1));

Уведомление о сообщении на рабочем столе

Эффект показан выше, разные системы/браузеры будут иметь разные стили. Люди часто спрашивают, как это достигается, но на самом деле это дополнительная функция HTML5.Notification, Чтобы получить больше информацииdeveloper.Mozilla.org/en-US/docs/…

Вставьте картинку

мониторpasteсобытие, получить вставленный контент, если он содержитFilesнабирайте контент, читайте контент и генерируйтеImageОбъект Примечание: изображение, полученное этим методом, будет намного больше, чем исходное изображение, поэтому его лучше сжать перед использованием.

@autobind
handlePaste(e) {
    const { items, types } = (e.clipboardData || e.originalEvent.clipboardData);

    // 如果包含文件内容
    if (types.indexOf('Files') > -1) {
        for (let index = 0; index < items.length; index++) {
            const item = items[index];
            if (item.kind === 'file') {
                const file = item.getAsFile();
                if (file) {
                    const that = this;
                    const reader = new FileReader();
                    reader.onloadend = function () {
                        const image = new Image();
                        image.onload = () => {
                            // 获取到 image 图片对象
                        };
                        image.src = this.result;
                    };
                    reader.readAsDataURL(file);
                }
            }
        }
        e.preventDefault();
    }
}

язык вещания

Это служба синтеза языка Baidu, спасибо Baidu. Подробную информацию см.Love.Baidu.com/Specialty/Speech…

историческая версия

Первоначальный вариант

image

Изменен фон и стиль

image

На основе реакции переписать, установитьfioraимя

image

Стиль стал смещаться в сторону двухмерного элемента, а также были добавлены некоторые новые функции.

image

Экспериментальная версия, которая не была запущена

image

В настоящее время работает онлайн-версия

image

позже

Если у вас есть какие-либо вопросы о Фиоре, не стесняйтесь, приходитеfiora.suisuijiang.com/Общение, я онлайн каждый день