Original address: github.com/yinxin630/b…
Note that you need to be familiar with JavaScript to read this article. This article will explain the design ideas of the core function points
Github.com/yinxin630/f…
fiora.suisuijiang.com/
preface
The project started at the end of 2015, when I first started to learn JavaScript. At that time, I just wanted to do a hands-on project. Later, with the in-depth study in the front-end field, we have been updating the technology stack, which is the fifth version after reconstruction
Thanks to the emergence of Node. js and React-Native, JSER has extended its tentacles to the server and APP ends. The server of this project is based on Node. js technology and koA framework. All data are stored in mongodb. The Client uses the React framework and uses Redux and immutable. Js to manage state. The client designs a minimalist UI style, and the APP is developed based on React-Native and Expo. The project is deployed on my Beggar version of Ali Cloud ECS, and the student computer is configured with a single core 1G memory
Server architecture
The server is responsible for two things:
- Provides a Websocket-based interface
- Provide the index.html response
The server uses the koA-socket package, which integrates socket. IO and implements the socket middleware mechanism. Based on the middleware mechanism, the server implements a set of interface routing
Each interface is an async function whose name is both the interface name and the socket event name
async login(ctx) {
return 'login success'
}
Copy the code
Then a route middleware is written to complete the route matching. When judging the route matching, the CTX object is used as the parameter to execute the route method, and the method return value is used as the interface return value
function noop() {}
@param {IO} IO koa socket I/O instance @param {Object} routes Route */
module.exports = function (io, _io, routes) {
Object.keys(routes).forEach((route) = > {
io.on(route, noop); // Register events
});
return async (ctx) => {
// Check whether the route exists
if (routes[ctx.event]) {
const { event, data, socket } = ctx;
// Execute the route and get the returned data
ctx.res = await routes[ctx.event]({
event, / / the event name
data, // Request data
socket, // User socket instance
io, / / koa - socket instance
_io, / / socket. IO instance}); }}; };Copy the code
Another important middleware is catchError, which is used to catch global exceptions. Assert is widely used in business processes to determine business logic, and when the condition is not met, the process is interrupted and the error message is returned. CatchError will catch business logic exceptions and fetch the error message back to the client
const assert = require('assert');
/** * global exception catch */
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); }}; };Copy the code
This is the core logic of the server side, based on which the interface is defined to form the business logic
In addition, the server is responsible for providing the index.html response, which is the home page of the client. The other resources of the client are stored on the CDN, which can relieve the bandwidth pressure of the server. However, the index. HTML cannot use strong cache, because it will make the update of the client uncontrollable, so the index. HTML is stored on the server
Client architecture
The client uses socket.io-client to connect to the server. After the connection is successful, the client requests the interface to try to log in. If the localStorage has no token or the interface returns an expired token, the client logs in as a tourist. Then ask for history messages from groups and friends
The client needs to listen for connect/disconnect/Message
connect
: The socket connection succeededdisconnect
Socket connection downmessage
A new message was received. Procedure
The client uses REDUx to manage data. The data that needs to be shared by components is stored in Redux. Only the data that needs to be used by the client is stored in the state of the component.
- User User information
- _id user id
- The username username
- Linkmans contact list, including groups, friends, and temporary sessions
- IsAdmin Whether isAdmin is an administrator
- Focus Indicates the id of the contact in focus
- Connect Connection status
- UI Client UI related and function switch
The data flow of the client has two main lines
- User action => Request interface => Return data => Update redux => View re-render
- Listen for new messages => process data => update redux => View re-render
The user’s system
User Schema definitions:
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-9 a zA - Z] {1, 2} | [\ u4e00 - \ u9eff]) {8} 1 $/.index: true,},salt: String.password: String.avatar: {
type: String,}});Copy the code
createTime
: Creation timelastLoginTime
: Last login time, used to clear the zombie idusername
: user nickname, which is also an accountsalt
Encryption of salt:password
: User passwordavatar
: indicates the URL of the user profile picture
User registration
Username/password is required to register the interface
const {
username, password
} = ctx.data;
assert(username, 'User name cannot be empty');
assert(password, 'Password cannot be empty');
Copy the code
Then check whether the user name already exists and obtain the default group. The newly registered user must join the default group
const user = awaitUser.findOne({ username }); assert(! user,'The username already exists');
const defaultGroup = await Group.findOne({ isDefault: true });
assert(defaultGroup, 'Default group does not exist');
Copy the code
Storing passwords in plain text is definitely not possible. Generate random salt and use salt to encrypt passwords
const salt = await bcrypt.genSalt$(saltRounds);
const hash = await bcrypt.hash$(password, salt);
Copy the code
Give the user a random default avatar, all cute girl ^_^, save the user information to the database
let newUser = null;
try {
newUser = await User.create({
username,
salt,
password: hash,
avatar: getRandomAvatar(),
});
} catch (err) {
if (err.name === 'ValidationError') {
return 'User name contains unsupported characters or exceeds length limit';
}
throw err;
}
Copy the code
Add the user to the default group and generate the user token. The token is a password-free login certificate stored in the client localStorage. The token contains the user ID, expiration time, and client information. The user ID and expiration time are easy to understand, the client information is to prevent token theft, we have tried to verify the consistency of the client IP, but the IP may change frequently, so that every automatic login of the user is judged as theft…
defaultGroup.members.push(newUser);
await defaultGroup.save();
const token = generateToken(newUser._id, environment);
Copy the code
The user ID is associated with the current socket connection. The server updates the current socket connection information in the socket table based on whether ctx.socket.user is undefined
ctx.socket.user = newUser._id;
await Socket.update({ id: ctx.socket.id }, {
user: newUser._id,
os, // Client system
browser, // Client browser
environment, // Client environment information
});
Copy the code
Finally, the data is returned to the client
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,
}
Copy the code
The user login
Fiora does not limit multiple logins; each user can log in from an unlimited number of terminals
There are three login cases:
- Visitors to login
- Token login
- User name/password login
Visitors can only view the default group messages, and cannot send messages, mainly to reduce the experience cost of first-time users
Token login is the most common method. The client first obtains the token from localStorage. When the token exists, the client uses the token to log in
let payload = null;
try {
payload = jwt.decode(token, config.jwtSecret);
} catch (err) {
return 'illegal token';
}
assert(Date.now() < payload.expires, 'Token has expired');
assert.equal(environment, payload.environment, 'Illegal login');
Copy the code
Find the user information from the database, update the last login time, find the user’s group, and add the socket to the group, and then find the user’s friends
const user = await User.findOne({ _id: payload.user }, { _id: 1.avatar: 1.username: 1 });
assert(user, 'User does not exist');
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 });
Copy the code
Update socket information, same as registration
ctx.socket.user = user._id;
await Socket.update({ id: ctx.socket.id }, {
user: user._id,
os,
browser,
environment,
});
Copy the code
Return data at last
The user name/password is different from the token login logic at the beginning. The token authentication data is not decoded to verify whether the user name exists and then verify whether the password matches
const user = await User.findOne({ username });
assert(user, 'This user does not exist');
const isPasswordCorrect = bcrypt.compareSync(password, user.password);
assert(isPasswordCorrect, 'Password error');
Copy the code
The logic is then consistent with token login
The messaging system
Send a message
The sendMessage interface takes three parameters:
to
: Indicates the object, group or user to be senttype
: Message typecontent
: Message content
Since group chat and private chat share the same interface, it is necessary to determine whether it is group chat or private chat and obtain the group ID or user ID. Group chat/private chat can be distinguished by the parameter to to indicate the corresponding group ID in group chat, and then obtain the group information. Private chat to is the result of id splicing between sender and receiver. Remove the sender ID to get the recipient ID, and then get the recipient information
let groupId = ' ';
let userId = ' ';
if (isValid(to)) {
const group = await Group.findOne({ _id: to });
assert(group, 'Group does not exist');
} else {
userId = to.replace(ctx.socket.user, ' ');
assert(isValid(userId), 'Invalid user ID');
const user = await User.findOne({ _id: userId });
assert(user, 'User does not exist');
}
Copy the code
Some message types need to be processed. Text messages determine the length and perform XSS processing. Invite messages determine whether the invited group exists and store information such as the inviter, group ID, and group name in the message body
let messageContent = content;
if (type === 'text') {
assert(messageContent.length <= 2048.'Message length is too long');
messageContent = xss(content);
} else if (type === 'invite') {
const group = await Group.findOne({ name: content });
assert(group, 'Target group does not exist');
const user = await User.findOne({ _id: ctx.socket.user });
messageContent = JSON.stringify({
inviter: user.username,
groupId: group._id,
groupName: group.name,
});
}
Copy the code
Store the new message to the database
let message;
try {
message = await Message.create({
from: ctx.socket.user,
to,
type,
content: messageContent,
});
} catch (err) {
throw err;
}
Copy the code
Then construct a message does not contain sensitive information data, the data contained in the sender’s id, username, avatar, including user name and image is the redundant data, consideration will be optimized after only a id, client user information maintenance, by id to match the user name and avatar, can save a lot of traffic If it is a group chat message, Private messaging is a bit more complicated because FiORA allows multiple logins, so you need to push all of the recipient’s online sockets first, and then the rest of fiORA’s online sockets
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); }}); }Copy the code
Finally, the message data is returned to the client, indicating that the message is successfully sent. To optimize user experience, the client immediately displays new information on the page when sending messages and requests the interface to send messages. If the message fails to be sent, it is deleted
Get historical messages
GetLinkmanHistoryMessages interface has two parameters:
linkmanId
: Contact ID, group ID, or combination of two user idsexistCount
: Indicates the number of existing messages
The detailed logic is relatively simple, according to the creation time reverse order to find the existing number + each time to obtain the number of messages, and then remove the existing number of messages and then reverse, is the new message sorted by time
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();
Copy the code
Return to the client
Receiving Push Messages
Client subscribes message event to receive new message socket.on(‘message’)
When receiving a new message, judge whether the contact exists in the state, if so, save the message to the corresponding contact, if not, it is a message of a temporary session, construct a temporary contact and obtain the historical message, and then add the temporary contact to the state. If the message is from another terminal, you do not need to create a contact
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')}Say to everybody: ';
} else {
title = `${message.from.username}Say to you: `; }}else {
// The contact does not exist and the message is sent by itself. No new contact is created
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}Say to you: `;
fetch('getLinkmanHistoryMessages', { linkmanId: newLinkman._id }).then(([err, res]) = > {
if (!err) {
action.addLinkmanMessages(newLinkman._id, res);
}
});
}
Copy the code
If the current chat page is in the background and the message notification switch is turned on, a desktop reminder pops up
if (windowStatus === 'blur' && state.getIn(['ui'.'notificationSwitch'])) {
notification(
title,
message.from.avatar,
message.type === 'text' ? message.content : ` [${message.type}] `.Math.random(),
);
}
Copy the code
If the sound switch is turned on, a new message prompt tone will sound
if (state.getIn(['ui'.'soundSwitch']) {const soundType = state.getIn(['ui'.'sound']);
sound(soundType);
}
Copy the code
If the language broadcast switch is turned on and the message is a text message, filter out the URL and # in the message, exclude the message with a length greater than 200, and then push the message to the message reading queue
if (message.type === 'text' && state.getIn(['ui'.'voiceSwitch']) {const text = message.content
.replace(/https? :\/\/(www\.) ? [9 - a - zA - Z0 - @ : %. _ + ~ # =] {2256} \. [a-z] {2, 6} \ b ([9 - a - zA - Z0 - @ : % _ +. ~ #? & / / =] *)/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}in${linkman.get('name')}Said `
:
`${message.from.username}` said to you;
if (text) {
voice.push(from! == prevFrom ?from + text : text, message.from.username);
}
prevFrom = from;
}
Copy the code
More Middleware
Restrict unlogged requests
Most interfaces are accessible only to logged in users. If the interface needs to log in and the socket connection has no user information, an “logged in” error is displayed
/** * Intercepts unlogged requests */
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 ='Please login and try again';
return;
}
await next();
};
};
Copy the code
Limit call frequency
To prevent interface flushing and reduce server pressure, the maximum number of interface requests per minute for a socket connection is 30
const MaxCallPerMinutes = 30;
/** * Limiting the frequency of interface calls */
module.exports = function () {
let callTimes = {};
setInterval((a)= > 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 = 'Interface calls frequently';
}
callTimes[socketId] = count + 1;
await next();
};
};
Copy the code
The little black house
The administrator account can add users to the small black room, the user added to the small black room can not request any interface, after 10 minutes automatically unban
/** * 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 = 'You've been locked in a dark room. Please reflect and try again.';
}
await next();
};
};
Copy the code
Other interesting things
expression
Emoticon is a Sprite image. Clicking on emoticon will insert the format as#(xx)
For example# (funny)
. When rendering the message, the text is replaced by a regular match<img>
And calculate the position of the expression in the Sprite image and render it on the pageIf SRC is not set, a border will be displayed. You need to set SRC to a transparent image
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 The ${- 30 * index}px;" onerror="this.style.display='none'" alt="${r}"> `;
}
returnr; }); }Copy the code
Emoji Search
Search results on www.doutula.com
const res = await axios.get(`https://www.doutula.com/search?keyword=The ${encodeURIComponent(keywords)}`);
assert(res.status === 200.'Emoji search failed, please try again');
const images = res.data.match(/data-original="[^ "]+"/g) | | [];return images.map(i= > i.substring(15, i.length - 1));
Copy the code
Desktop Message Notification
Many people ask how this is implemented. In fact, it is a new feature of HTML5 called Notification. For more information, check out developer.mozilla.org/en-US/docs/…
Paste the hair figure
Listen for the paste event to get the paste content, and if it contains content of type Files, read the content and generate an Image object. Note: the image obtained in this way will be much larger than the original image, so it is best to compress it before using
@autobind
handlePaste(e) {
const { items, types } = (e.clipboardData || e.originalEvent.clipboardData);
// If file contents are included
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 = (a)= > {
// Get the image object
};
image.src = this.result; }; reader.readAsDataURL(file); } } } e.preventDefault(); }}Copy the code
Language broadcasts
This is baidu’s language synthesis service, thanks Baidu. Check out ai.baidu.com/tech/speech…
Version history
Original version
Changed the background and style
React rewrites the fiora name
The style is becoming more quadratic, with some new features
An experimental version that never made it online
The current version of online running
The latter
If you have any questions about Fiora, please feel free to contact fiora.suisuijiang.com/. I will be online every day