千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(四):服务端搭建与总结

本文篇幅较长,预计阅读时长1-2h,欢迎收藏+点赞+关注。

这是《千亿级IM独立开发指南!全球即时通讯全套代码4小时速成》的第四篇:《服务端搭建与总结》
系列文章可参考:

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(一):功能设计与介绍

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(二):UI设计与搭建

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(三):App内部流程与逻辑(上)

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(三):App内部流程与逻辑(下)

四、服务端搭建与总结

这篇终于进入了最后的部分:服务端。
随着这部分的完成,一个完整的,可制支撑千亿级消息的IM便白嫖完成!所以,我们加一把劲,来看一下这最后的服务端部分。

1. 服务端选型

服务端的开发,首当其冲,且也是最重要的,便是服务端的选型。根据前两篇的需求分析和功能设计,我们有三个突出的核心需求:

  1. 能与 RTM 服务端交互:这意味着云上曲率官方必须提供对应语言的Server 端 SDK
  2. 支持 HTTP/HTTPS 以 GET 和 POST 的方式访问
  3. 能以简单且快速的方式,开发我们所需求的业务

云上曲率 RTM 其实提供了很多不同的SDK用于客户与RTM,官网上面直接列出的有 Go、PHP、C++、Python、Java、C# 六种。其实还存在其他的隐藏款,比如 Node.js。

在这些SDK中,从简单和方便程度上而言,快速开发有以下四个选项:
● C++
● Go
● Python
● Node.js

首先,因为Node.js SDK计划会有重大更新,当前 GitHub 上是较老版本,所以我们暂不选择。这也是Node.js SDK 成为隐藏款的核心原因。

而 Python 和 Go 对于 HTTP/HTTPS 来说,则算是经典候选。直接启动 HTTP 服务器,接入 RTM 对应语言的SDK,实现相关的业务代码即可。而我们这里选择C++。
我们选择C++的原因有两点:一是在FPNN框架的加持下,C++开发者将不再需要去处理任何 HTTP/HTTPS 相关的支持,HTTP/HTTPS 的支持可以被完全透明化。第二点就是,整个RTM服务系统就是基于C++ FPNN的框架进行开发的,所以我们可以更好地与RTM服务器进行交互。

虽然RTM服务端的C++ SDK是用 FPNN C++ SDK进行开发,但 FPNN C++ SDK 是FPNN 框架的特化子集,大部分情况下几乎无需改动便可从FPNN C++ SDK改为由FPNN框架提供基础支持。这样我们便可轻松实现开发需求。
但接下来,我们会采用更加简单的操作!

2. FPNN 框架的配置

首先从GitHub上面下载FPNN框架的最新发行版,目前是 1.1.3 版本。
注意:FPNN 框架目前仅支持 CentOS、Ubuntu 和 MacOS 三个操作系统,WIndows 仅有 C++ Widnows SDK。C++ SDK 不含服务器和HTTP/HTTPS等功能支持。

按照“FPNN安装与集成”进行环境配置和框架编译。
注意:如果编译和运行环境不是亚马逊AWS,而是阿里云、腾讯云等,或者自己的内网虚拟机,切记根据“FPNN注意事项”进行修改和配置。否则服务可能无响应。因为在默认情况下,适配的是亚马逊AWS的运行环境。

3. 服务器框架搭建

3.1. 服务器框架搭建

采用FPNN框架开发的服务,其实必须的就3个文件:一个C++代码文件,一个运行配置文件,一个Makefile
在这里,我们把业务代码和框架代码分离:将负责具体业务请求处理的 QuestProcessor 类的实现和服务框架分开,一共产生5个文件:Makefile、IMDemoServer.cpp、QuestProcessor.h、QuestProcessor.cpp、im.conf。这也是采用FPNN框架进行开发的推荐做法。

首先是服务框架 IMDemoServer.cpp,如果我们不修改业务请求处理类的名称的话,以下代码无需修改,这也算是FPNN框架开发的标准代码:

IMDemoServer.cpp:

#include 
#include "TCPEpollServer.h"
#include "QuestProcessor.h"
#include "Setting.h"

using namespace fpnn;

int main(int argc, char* argv[])
{
    if (argc != 2)
    {
        std::cout<<"Usage: "<setQuestProcessor(std::make_shared());
    if (server->startup())
        server->run();

    return 0;
}

对,整个服务器框架就算是完成了。当然,如果不采用HTTP或者TCP,而改为UDP,则将代码中的 TCPEpollServer 直接换成 UDPEpollServer,整个服务就从TCP或者HTTP/HTTPS服务,变成了UDP服务。

然后是业务框架:

QuestProcessor.h:

#ifndef QuestProcessor_H
#define QuestProcessor_H

#include "IQuestProcessor.h"

using namespace fpnn;

class QuestProcessor: public IQuestProcessor
{
    QuestProcessorClassPrivateFields(QuestProcessor)
    
public:
    virtual ~QuestProcessor() {}

    QuestProcessorClassBasicPublicFuncs
};

#endif

QuestProcessor.cpp:

`#include "QuestProcessor.h"`

嗯,FPNN空的业务框架这样就完成了!
鉴于我们需要处理 userLogin、userRegister、createGroup、joinGroup、createRoom、lookup 六个请求,所以我们修改业务框架代码如下:

QuestProcessor.h:

#ifndef QuestProcessor_H
#define QuestProcessor_H

#include "IQuestProcessor.h"

using namespace fpnn;

class QuestProcessor: public IQuestProcessor
{
    QuestProcessorClassPrivateFields(QuestProcessor)
    
public:
    FPAnswerPtr userLogin(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci);
    FPAnswerPtr userRegister(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci);
    FPAnswerPtr createGroup(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci);
    FPAnswerPtr joinGroup(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci);
    FPAnswerPtr createRoom(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci);
    FPAnswerPtr lookup(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci);

    QuestProcessor();
    virtual ~QuestProcessor() {}

    QuestProcessorClassBasicPublicFuncs
};

#endif

QuestProcessor.cpp:

#include "FPLog.h"
#include "QuestProcessor.h"

QuestProcessor::QuestProcessor()
{
    registerMethod("userLogin", &QuestProcessor::userLogin);
    registerMethod("userRegister", &QuestProcessor::userRegister);
    registerMethod("createGroup", &QuestProcessor::createGroup);
    registerMethod("joinGroup", &QuestProcessor::joinGroup);
    registerMethod("createRoom", &QuestProcessor::createRoom);
    registerMethod("lookup", &QuestProcessor::lookup);
}

FPAnswerPtr QuestProcessor::userLogin(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci)
{
    //-- TODO
    return nullptr;
}

FPAnswerPtr QuestProcessor::userRegister(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci)
{
    //-- TODO
    return nullptr;
}

FPAnswerPtr QuestProcessor::createGroup(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci)
{
    //-- TODO
    return nullptr;
}

FPAnswerPtr QuestProcessor::joinGroup(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci)
{
    //-- TODO
    return nullptr;
}

FPAnswerPtr QuestProcessor::createRoom(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci)
{
    //-- TODO
    return nullptr;
}

FPAnswerPtr QuestProcessor::lookup(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci)
{
    //-- TODO
    return nullptr;
}

到此,业务框架也就搭建完成。

然后是 FPNN的标准配置文件:

im.conf:

FPNN.server.listening.ip = 
FPNN.server.listening.port = 13601
FPNN.server.name = IMDemoServer

FPNN.server.log.level = WARN
FPNN.server.log.endpoint = std::cout
FPNN.server.log.route = IMDemoServer

配置中,监听IP为空表示我们在本机所有IP地址,或者网络接口上进行监听。
监听端口为 13601,监听IPv4,暂不启动IPv6的监听。
然后日志输出等级为 WARN,即仅输出警告,及警告以上级别的日志。警告以下级别的日志将被忽略。
日志向标准输出输出。

因为FPNN框架不是专门的HTTP/HTTPS服务框架,而只是带了HTTP/HTTPS协议支持,所以这里我们要增加一行配置,以便打开对 HTTP 的支持:

im.conf:

# FPNN 服务支持 HTTP/HTTPS 访问
FPNN.server.http.supported = true

因为我们没有HTTPS证书,所以暂时仅启动HTTP支持。如果需要HTTPS支持,则在获取证书后,按照“FPNN HTTP/HTTPS & webSocket (ws/wss) 支持”一文进行配置即可。

最后,是我们的Makfile,这也是从FPNN 框架开发的标准模版修改而来:

Makefile:

EXES_SERVER = IMDemoServer

FPNN_DIR = ../../../infra-fpnn
CFLAGS +=
CXXFLAGS +=
CPPFLAGS += -I$(FPNN_DIR)/extends -I$(FPNN_DIR)/core -I$(FPNN_DIR)/proto -I$(FPNN_DIR)/base -I$(FPNN_DIR)/proto/msgpack -I$(FPNN_DIR)/proto/rapidjson
LIBS += -L$(FPNN_DIR)/core -L$(FPNN_DIR)/proto -L$(FPNN_DIR)/extends -L$(FPNN_DIR)/base -lfpnn

OBJS_SERVER = IMDemoServer.o QuestProcessor.o

all: $(EXES_SERVER)

clean:
    $(RM) *.o $(EXES_SERVER)
include $(FPNN_DIR)/def.mk

其中,FPNN_DIR 指明了FPNN框架的路径;OBJS_SERVER 指明了相关的cpp代码文件对应的目标文件。
其余一般情况下,直接照抄即可。

3.2. RTM Server C++ SDK 接入

一般情况下,我们直接引入 RTM C++ Server SDK 即可。但鉴于RTM本身就是用FPNN框架进行开发,且我们所需要和RTM打交道的接口很少,而且FPNN体系开发又很容易,所以我们这次不引入 RTM C++ Server SDK,而是通过FPNN协议直接访问 RTM 服务。
但访问RTM需要服务端密钥签名,所以我们直接从 RTM C++ Server SDK 中摘出两个文件 RTMMidGenerator.h 和 RTMMidGenerator.cpp 加入我们的服务代码中。两个文件相当简单,总代码量不到60行,这里也就不再具体分析。
在 QuestProcessor.cpp 中引入 RTMMidGenerator.h,并初始化 RTMMidGenerator 类:

QuestProcessor.cpp:

... ...
#include "RTMMidGenerator.h"
... ...

QuestProcessor::QuestProcessor()
{
    ... ...
    RTMMidGenerator::init();
}

然后修改我们的 Makefile,将 OBJS_SERVER 添加一个参数 RTMMidGenerator.o 即可:

Makefile:

OBJS_SERVER = IMDemoServer.o RTMMidGenerator.o QuestProcessor.o

4. 功能的开发

4.1. 与 RTM 服务器的交互

首先,我们需要登录云上曲率的用户控制台,在控制台左侧,“控制台概览”中,选择“实时信令”条目:

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(四):服务端搭建与总结_第1张图片

进入实时信令的项目选择页(需注册云上曲率官网账号)选择对应的项目,进入项目控制台。
在项目控制台左侧列表中,选择“服务配置”:

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(四):服务端搭建与总结_第2张图片

然后在右侧,便可看到服务器项目需配置的数据信息:

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(四):服务端搭建与总结_第3张图片

我们记录下项目编号、服务端SDK接入点,以及密钥,将其配置入 im.conf

RTM.config.endpoint = rtm-nx-back.ilivedata.com:13315
RTM.config.pid = 80000253
RTM.config.secret = xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

之后,我们来加入与RTM服务器通信的模块。

既然RTM服务器由FPNN框架开发,各个平台和语言的SDK又都由对应的FPNN SDK开发,那我们直接用FPNN Client 和RTM 服务器连接即可。编辑代码,在代码中加入 TCPClient 的使用,以及与RTM服务器通讯需要的信息:

QuestProcessor.h:

... ...
#include "TCPClient.h"
... ...
    
class QuestProcessor: public IQuestProcessor
{
    ... ...
    
    int _pid;
    std::string _secret;
    
    TCPClientPtr _rtmServerClient;
    
    ... ...
}

QuestProcessor.cpp:

... ...  
#include "Setting.h"  
... ...
    
QuestProcessor::QuestProcessor()
{
    ... ...
    std::string endpoint = Setting::getString("RTM.config.endpoint");
    _rtmServerClient = TCPClient::createClient(endpoint);
    _rtmServerClient->keepAlive();
    _rtmServerClient->setQuestTimeout(10);
    
    _secret = Setting::getString("RTM.config.secret");
    _pid = Setting::getInt("RTM.config.pid");
    
    ... ...
}

其中头文件 Setting.h 中包含的静态类 calss Setting 负责从配置文件中提取信息。
我们通过接入点创建了 TCPClient 之后,开启了 FPNN 的链接包活功能,并修改了默认的超时时间。
并从配置文件,加载了项目编号,以及访问密钥。

4.2. 数据签名

从RTM公开的SDK我们发现,服务端访问RTM服务,需要对接口和数据进行签名,于是加入辅助函数 makeSignAndSalt():

QuestProcessor.h:

class QuestProcessor: public IQuestProcessor
{
    ... ...
    
    void makeSignAndSalt(int32_t ts, const std::string& cmd, std::string& sign, int64_t& salt);
    
    ... ...
}

QuestProcessor.cpp:

... ...

#include "hex.h"
#include "md5.h"

... ...
    
void QuestProcessor::makeSignAndSalt(int32_t ts, const std::string& cmd, std::string& sign, int64_t& salt)
{
    salt = RTMMidGenerator::genMid();
    std::string content = std::to_string(_pid) + ":" + _secret + ":" + std::to_string(salt) + ":" + cmd + ":" + std::to_string(ts);
    
    unsigned char digest[16];
    md5_checksum(digest, content.c_str(), content.size());
    char hexstr[32 + 1];
    Hexlify(hexstr, digest, sizeof(digest));

    sign.assign(hexstr); 
}

... ...

4.3. 数据存储功能

我们开发这个后端服务的核心目的,便是对用户信息的管理。于是我们需要保存、查找和增改相关数据。
为了简化起见,我们采用json格式进行本地存储。虽然FPNN框架带有RapidJSON的最新版本,但直接用RapidJSON进行操作,还是太过麻烦和不便。于是我们使用FPNN框架自带的另外一个Json库FPJson。采用FPJson,不仅简单方便,不论多少层级的数据访问,或者多么复杂的STL组合容器数据打包,FPJson一律一行代码直接搞定。而且对于我们目前的需求,甚至都可以直接作为容器使用,而无需以任何形式处理json格式。

因此,我们决定采用 FPJson,进行数据的管理和存储。此外,我们希望当服务器启动的时候,加载本地存储的数据;当服务器停止的时候,保存相关的数据到磁盘。

编辑配置文件 im.conf,增加对数据保存路径的配置:

# 用户账号信息保存路径
IMDemoServer.Store.path = ./im.users.json

编辑 QuestProcessor 类,加入对 FPJson 的引用,以及对服务器启动和停止事件的处理。

QuestProcessor.h:

... ...
    
#include "FPJson.h"
    
... ...
    
class QuestProcessor: public IQuestProcessor
{
    ... ...
    
    std::mutex _mutex;
    JsonPtr _root;
    
    ... ...
    
public:
    virtual void start();
    virtual void serverStopped();
    
    ... ...
}

QuestProcessor.cpp:

#include "FileSystemUtil.h"

... ...
    
void QuestProcessor::start()
{
    std::string userFile = Setting::getString("IMDemoServer.Store.path");
    std::string userData;
    FileSystemUtil::readFileContent(userFile, userData);
    if (userData.size() > 0)
        _root = Json::parse(userData.c_str());
        
    if (!_root)
    {
        _root.reset(new Json());
        (*_root)["nextUid"] = 1;
        (*_root)["nextGid"] = 1;
        (*_root)["nextRid"] = 1;
    }
}

void QuestProcessor::serverStopped()
{
    std::string userData = _root->str();
    std::string userFile = Setting::getString("BizServer.Store.path");
    FileSystemUtil::saveFileContent(userFile, userData);
}

至此,数据的加载、保存和基础管理,开发完毕。
同时 im.conf 后续也不再修改。

4.4. userLogin

我们规定 userLogin 接口需要两个参数:username 和 pwd。两者皆为字符串形式。
返回三个参数:pid、uid和token。其中pid为项目编号,整型;uid为用户唯一数字ID,整型;token为从RTM服务器获取的登陆密钥,字符串类型。

虽然我们在iOS篇中,规定是通过HTTP/HTTPS 的GET方式访问,但FPNN框架本身支持HTTP、HTTPS、FPNN协议(TCP & UDP)、FPNN 加密协议(TCP & UDP)、FPNN over TLS、WebSocket(ws)、安全的WebSocket(wss) 几种方式访问,且后续的其他版本的App可能会以不同的形式访问,所以我们在这里需要兼容以上几种协议传输的参数获取。好在对于FPNN框架,仅有 HTTP/GET和其他方式参数获取不同,所以 userLogin 接口及相关代码如下:

QuestProcessor.h:

class QuestProcessor: public IQuestProcessor
{
    ... ...
    
    FPQuestPtr genTokenQuest(int64_t uid);
    void getToken(int64_t uid, std::shared_ptr async);
    
    ... ...

public:

    ... ...
    
    FPAnswerPtr userLogin(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci);
    
    ... ...
}

QuestProcessor.cpp:

FPQuestPtr QuestProcessor::genTokenQuest(int64_t uid)
{
    int32_t ts = slack_real_sec();
    std::string sign;
    int64_t salt;
    makeSignAndSalt(ts, "gettoken", sign, salt);

    FPQWriter qw(5, "gettoken");
    qw.param("pid", _pid);
    qw.param("sign", sign);
    qw.param("salt", salt);
    qw.param("ts", ts);
    qw.param("uid", uid);
    return qw.take();
}

void QuestProcessor::getToken(int64_t uid, std::shared_ptr async)
{
    int pid = _pid;
    FPQuestPtr tokenQuest = genTokenQuest(uid);
    bool launchAsync = _rtmServerClient->sendQuest(tokenQuest, [uid, pid, async](FPAnswerPtr answer, int errorCode){

        if (errorCode == FPNN_EC_OK)
        {
            FPAReader ar(answer);
            FPAWriter aw(3, async->getQuest());
            aw.param("pid", pid);
            aw.param("uid", uid);
            aw.param("token", ar.getString("token"));

            async->sendAnswer(aw.take());
        }
        else
            async->sendErrorAnswer(errorCode, "BizServer error.");
    });

    if (launchAsync == false)
        async->sendErrorAnswer(FPNN_EC_CORE_UNKNOWN_ERROR, "BizServer error. You can retry.");
}

FPAnswerPtr QuestProcessor::userLogin(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci)
{
    std::string username, password;
    if (quest->isHTTP())
    {
        //-- HTTP/HTTPS GET 访问
        username = quest->http_uri("username");
        password = quest->http_uri("pwd");

        //-- 如果是 POST 访问,而不是 GET 访问
        if (username.empty())
            username = args->wantString("username");

        if (password.empty())
            password = args->wantString("pwd");
    }
    else
    {
        //-- FPNN/WebSocket/HTTP POST/HTTPS POST 访问
        username = args->wantString("username");
        password = args->wantString("pwd");
    }

    int64_t uid = 0;
    bool passwordMached = true;
    {
        std::unique_lock lck(_mutex);
        if ((*_root)["account"].exist(username))
        {
            if ((std::string)((*_root)["account"][username]["pwd"]) == password)
                uid = (*_root)["account"][username]["uid"];
            else
                passwordMached = false;
        }
    }

    if (passwordMached == false)
        return FpnnErrorAnswer(quest, FPNN_EC_CORE_UNKNOWN_ERROR, "Password is wrong!");

    if (uid == 0)
        return FpnnErrorAnswer(quest, FPNN_EC_CORE_UNKNOWN_ERROR, "User is unregistered!");

    getToken(uid, genAsyncAnswer(quest));

    return nullptr;
}

其中,函数 genTokenQuest() 生成向 RTM 服务集群请求用户登陆 token 的请求数据;函数 getToken() 则调用 TCPClient 向 RTM集群发送获取用户登陆 token 的请求,并在异步回调中生成对 App 的返回数据。

而在 userLogin() 函数中,则展示了如何从FPNN框架所支持的各种访问形式中,获取对应的输入参数。
然后通过 FPJson 检查用户是否存在。当用户不存在时,返回对应的错误;当用户存在时,产生一个异步应答对象,交给函数 getToken(),以便当用户登陆 touken 请求返回时,能对 App 的请求进行异步应答。而期间如果有任何错误,导致流程中断,FPNN的异步应答对象将自动对App的请求进行回应。

4.5. userRegister

用户注册流程与用户登录流程高度类似。
当 FPJson 确认注册的用户不存在,提交的用户名可以注册后,记录用户的注册信息,然后通过 getToken() 函数,向RTM集群请求用户的登录token。具体代码如下:

QuestProcessor.cpp:

FPAnswerPtr QuestProcessor::userRegister(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci)
{
    std::string username, password;
    if (quest->isHTTP())
    {
        //-- HTTP/HTTPS GET 访问
        username = quest->http_uri("username");
        password = quest->http_uri("pwd");

        //-- 如果是 POST 访问,而不是 GET 访问
        if (username.empty())
            username = args->wantString("username");

        if (password.empty())
            password = args->wantString("pwd");
    }
    else
    {
        //-- FPNN/WebSocket/HTTP POST/HTTPS POST 访问
        username = args->wantString("username");
        password = args->wantString("pwd");
    }

    int64_t uid = 0;
    {
        std::unique_lock lck(_mutex);
        if ((*_root)["account"].exist(username) == false)
        {
            (*_root)["account"][username]["pwd"] = password;

            uid = (*_root)["nextUid"];

            (*_root)["nextUid"] = uid + 1;
            (*_root)["account"][username]["uid"] = uid;
        }
    }

    if (uid == 0)
        return FpnnErrorAnswer(quest, FPNN_EC_CORE_UNKNOWN_ERROR, "Username is existed!");

    getToken(uid, genAsyncAnswer(quest));

    return nullptr;
}

4.6. createGroup

从创建群组开始,为了后续方便,我们摘出两个通用函数 sendAsyncQuest() 以及 extraParams()。
因为在后续的接口中,我们需要返回给App的应答其实已经准备好了,但是还需要RTM做进一步的操作才能返回,所以我们提取出了异步应答函数 sendAsyncQuest():

QuestProcessor.h:

class QuestProcessor: public IQuestProcessor
{
    ... ...
    
    void sendAsyncQuest(FPQuestPtr quest, std::shared_ptr async, FPAnswerPtr realAnswer = nullptr);
    
    ... ...
}

QuestProcessor.cpp:

void QuestProcessor::sendAsyncQuest(FPQuestPtr quest, std::shared_ptr async, FPAnswerPtr realAnswer)
{
    bool launchAsync = _rtmServerClient->sendQuest(quest, [async, realAnswer](FPAnswerPtr answer, int errorCode){

        if (errorCode == FPNN_EC_OK)
        {
            if (realAnswer)
                async->sendAnswer(realAnswer);
            else
                async->sendEmptyAnswer();
        }
        else
            async->sendErrorAnswer(errorCode, "BizServer error.");
    });

    if (launchAsync == false)
        async->sendErrorAnswer(FPNN_EC_CORE_UNKNOWN_ERROR, "BizServer error. You can retry.");
}

而后续的接口,大部分输入参数高度一致,所以我们有了统一的参数提取函数 extraParams():

QuestProcessor.h:

class QuestProcessor: public IQuestProcessor
{
    ... ...
    
    FPAnswerPtr extraParams(const FPReaderPtr args, const FPQuestPtr quest, const char* xidKey, const char* xnameKey,
        int64_t &xid, std::string& xname);
    
    ... ...
}

QuestProcessor.cpp:

FPAnswerPtr QuestProcessor::extraParams(const FPReaderPtr args, const FPQuestPtr quest,
            const char* xidKey, const char* xnameKey, int64_t &xid, std::string& xname)
{
    if (quest->isHTTP())
    {
        //-- HTTP/HTTPS GET 访问
        std::string xidString = quest->http_uri(xidKey);
        if (xidString.empty())
            xid = 0;
        else
            xid = std::stoll(xidString);

        xname = quest->http_uri(xnameKey);

        //-- 如果是 POST 访问,而不是 GET 访问
        if (xid == 0)
            xid = args->wantInt(xidKey);

        if (xname.empty())
            xname = args->wantString(xnameKey);
    }
    else
    {
        //-- FPNN/WebSocket/HTTP POST/HTTPS POST 访问
        xid = args->wantInt(xidKey);
        xname = args->wantString(xnameKey);
    }

    if (xid == 0)
        return FpnnErrorAnswer(quest, FPNN_EC_CORE_UNKNOWN_ERROR, std::string("Invalid ").append(xidKey).append("!").c_str());

    if (xname.empty())
        return FpnnErrorAnswer(quest, FPNN_EC_CORE_UNKNOWN_ERROR, std::string("Invalid ").append(xnameKey).append("!").c_str());

    return nullptr;
}

在以上基础上,我们再来实现创建群组的功能:

QuestProcessor.cpp:

FPAnswerPtr QuestProcessor::createGroup(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci)
{
    int64_t uid = 0;
    std::string groupname;

    FPAnswerPtr answer = extraParams(args, quest, "uid", "group", uid, groupname);
    if (answer)
        return answer;

    int64_t gid = 0;

    {
        std::unique_lock lck(_mutex);
        if ((*_root)["group"].exist(groupname) == false)
        {
            gid = (*_root)["nextGid"];

            (*_root)["nextGid"] = gid + 1;
            (*_root)["group"][groupname] = gid;
        }
    }

    if (gid == 0)
        return FpnnErrorAnswer(quest, FPNN_EC_CORE_UNKNOWN_ERROR, "Group is existed!");

    int32_t ts = slack_real_sec();
    std::string sign;
    int64_t salt;
    makeSignAndSalt(ts, "addgroupmembers", sign, salt);

    FPQWriter qw(6, "addgroupmembers");
    qw.param("pid", _pid);
    qw.param("sign", sign);
    qw.param("salt", salt);
    qw.param("ts", ts);
    qw.param("gid", gid);
    qw.param("uids", std::set{uid});

    FPAWriter aw(1, quest);
    aw.param("gid", gid);

    sendAsyncQuest(qw.take(), genAsyncAnswer(quest), aw.take());

    return nullptr;
}

在 createGroup 中,我们先验证需要创建的群组唯一名称并不存在,然后给群组分配唯一数字ID,记录群组唯一名称和ID信息,然后向 RTM服务集群提交 addgroupmembers 请求。当 RTM 服务集群对 addgroupmembers 请求通过后,再返回给 App 对应的应答数据。

4.7. joinGroup

加入群组与创建群组类似。
当确认群组存在后,我们的服务器便向 RTM服务集群提交 addgroupmembers 请求。当 RTM 服务集群对 addgroupmembers 请求通过后,再返回给 App 对应的应答数据。

QuestProcessor.cpp:

FPAnswerPtr QuestProcessor::joinGroup(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci)
{
    int64_t uid = 0;
    std::string groupname;

    FPAnswerPtr answer = extraParams(args, quest, "uid", "group", uid, groupname);
    if (answer)
        return answer;

    int64_t gid = 0;

    {
        std::unique_lock lck(_mutex);
        if ((*_root)["group"].exist(groupname))
            gid = (*_root)["group"][groupname];
        else
            return FpnnErrorAnswer(quest, FPNN_EC_CORE_UNKNOWN_ERROR, "Group is not existed!");        
    }

    int32_t ts = slack_real_sec();
    std::string sign;
    int64_t salt;
    makeSignAndSalt(ts, "addgroupmembers", sign, salt);

    FPQWriter qw(6, "addgroupmembers");
    qw.param("pid", _pid);
    qw.param("sign", sign);
    qw.param("salt", salt);
    qw.param("ts", ts);
    qw.param("gid", gid);
    qw.param("uids", std::set{uid});

    FPAWriter aw(1, quest);
    aw.param("gid", gid);

    sendAsyncQuest(qw.take(), genAsyncAnswer(quest), aw.take());

    return nullptr;
}

4.8. createRoom

创建房间与创建群组高度相似。区别只是一个是群组,一个是房间。而且房间App端可以直接加入,因此不再有对 RTM 集群的请求操作。

QuestProcessor.cpp:

FPAnswerPtr QuestProcessor::createRoom(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci)
{
    std::string roomname;

    if (quest->isHTTP())
    {
        //-- HTTP/HTTPS GET 访问
        roomname = quest->http_uri("room");

        //-- 如果是 POST 访问,而不是 GET 访问
        if (roomname.empty())
            roomname = args->wantString("room");
    }
    else
    {
        //-- FPNN/WebSocket/HTTP POST/HTTPS POST 访问
        roomname = args->wantString("room");
    }

    int64_t rid = 0;

    {
        std::unique_lock lck(_mutex);
        if ((*_root)["room"].exist(roomname) == false)
        {
            rid = (*_root)["nextRid"];

            (*_root)["nextRid"] = rid + 1;
            (*_root)["room"][roomname] = rid;
        }
        else
            rid = (*_root)["room"][roomname];
    }

    FPAWriter aw(1, quest);
    aw.param("rid", rid);

    return aw.take();
}

4.9. lookup


最后,是查询功能。
查询其实就是在Json的数据字典中,进行查找或遍历。为了简单易于理解,这里没有做任何的优化和技巧处理。流程也很简单,不再做详细说明。相关代码如下:

QuestProcessor.cpp:

FPAnswerPtr QuestProcessor::lookup(const FPReaderPtr args, const FPQuestPtr quest, const ConnectionInfo& ci)
{
    std::set uids, gids, rids;
    std::set users, groups, rooms;

    uids = args->get("uids", uids);
    gids = args->get("gids", gids);
    rids = args->get("rids", rids);

    users = args->get("users", users);
    groups = args->get("groups", groups);
    rooms = args->get("rooms", rooms);

    std::map userResult, groupResult, roomResult;

    //====================================//
    {
        std::unique_lock lck(_mutex);

        for (auto& username: users)
        {
            if ((*_root)["account"].exist(username))
                userResult[username] = (*_root)["account"][username]["uid"];
        }

        for (auto& groupname: groups)
        {
            if ((*_root)["group"].exist(groupname))
                groupResult[groupname] = (*_root)["group"][groupname];
        }

        for (auto& roomname: rooms)
        {
            if ((*_root)["room"].exist(roomname))
                roomResult[roomname] = (*_root)["room"][roomname];
        }

        if (uids.size() > 0)
        {
            const std::map * accountDict = _root->getDict("account");
            for (auto& node: *accountDict)
            {
                int64_t uid = (int64_t)(node.second->wantInt("uid"));
                if (uids.find(uid) != uids.end())
                {
                    userResult[node.first] = uid;
                    uids.erase(uid);

                    if (uids.empty())
                        break;
                }
            }
        }
            
        if (gids.size() > 0)
        {
            const std::map * groupDict = _root->getDict("group");
            for (auto& node: *groupDict)
            {
                int64_t gid = *(node.second);
                if (gids.find(gid) != gids.end())
                {
                    groupResult[node.first] = gid;
                    gids.erase(gid);

                    if (gids.empty())
                        break;
                }
            }
        }

        if (rids.size() > 0)
        {
            const std::map * roomDict = _root->getDict("room");
            for (auto& node: *roomDict)
            {
                int64_t rid = *(node.second);
                if (rids.find(rid) != rids.end())
                {
                    roomResult[node.first] = rid;
                    rids.erase(rid);

                    if (rids.empty())
                        break;
                }
            }
        }
    }
    //====================================//

    FPAWriter aw(3, quest);
    aw.param("users", userResult);
    aw.param("groups", groupResult);
    aw.param("rooms", roomResult);

    return aw.take();
}

至此,整个 IMDemo 服务端边开发完成。
Server 端完整代码请参见:https://github.com/highras/rtm-teaching-demo/tree/main/rtm-imdemo/demoServer

5. 编译 & 运行

编译好 FPNN框架后,进入 IMDemoServer 的代码目录,直接 make 就行。

[swxlion@ip-10-65-5-131 demoServer]$ make
g++ -c  -std=c++11 -DHOST_PLATFORM_AWS -I../../../infra-fpnn/extends -I../../../infra-fpnn/core -I../../../infra-fpnn/proto -I../../../infra-fpnn/base -I../../../infra-fpnn/proto/msgpack -I../../../infra-fpnn/proto/rapidjson -g -Wall -Werror -fPIC -O2  -o BizServer.o BizServer.cpp
g++ -c  -std=c++11 -DHOST_PLATFORM_AWS -I../../../infra-fpnn/extends -I../../../infra-fpnn/core -I../../../infra-fpnn/proto -I../../../infra-fpnn/base -I../../../infra-fpnn/proto/msgpack -I../../../infra-fpnn/proto/rapidjson -g -Wall -Werror -fPIC -O2  -o RTMMidGenerator.o RTMMidGenerator.cpp
g++ -c  -std=c++11 -DHOST_PLATFORM_AWS -I../../../infra-fpnn/extends -I../../../infra-fpnn/core -I../../../infra-fpnn/proto -I../../../infra-fpnn/base -I../../../infra-fpnn/proto/msgpack -I../../../infra-fpnn/proto/rapidjson -g -Wall -Werror -fPIC -O2  -o QuestProcessor.o QuestProcessor.cpp
g++  -std=c++11 -DHOST_PLATFORM_AWS -I../../../infra-fpnn/extends -I../../../infra-fpnn/core -I../../../infra-fpnn/proto -I../../../infra-fpnn/base -I../../../infra-fpnn/proto/msgpack -I../../../infra-fpnn/proto/rapidjson -g -Wall -Werror -fPIC -O2  -o BizServer BizServer.o RTMMidGenerator.o QuestProcessor.o -L../../../infra-fpnn/core -L../../../infra-fpnn/proto -L../../../infra-fpnn/extends -L../../../infra-fpnn/base -lfpnn -O2 -rdynamic -lstdc++ -lfpnn -lfpproto -lextends -lfpbase -lpthread -lz -lssl -lcrypto -lcurl -ltcmalloc
[swxlion@ip-10-65-5-131 demoServer]$ 

运行:

[swxlion@ip-10-65-5-131 demoServer]$ ./IMDemoServer im.conf

6. 项目后记

到此,IMDemo服务端开发完毕,整个IMDemo项目已经处于完全可用的状态。
整个项目的运行效果可参见下面的动画演示:

从零开始构建一个像样可用的即时通讯APP

完整的项目代码请参见:https://github.com/highras/rtm-teaching-demo
出于篇幅的原因,我们没有介绍文件的发送、离线语音、实时音视频,以及多语言翻译、语音识别、文本审核、图像审核、音视频审核等功能。这些将后续以本Demo扩展的方式,或者另起新篇进行介绍。如果有感兴趣的同学,可以先行浏览云上曲率官网进行了解。

安全性警告

本服务器代码仅做演示和讲解之用,没有做进一步的安全措施。
此外,对于用户,仅做了用户名的冲突检测,没有做进一步的安全管控。
实际项目中,请避免在公网执行非加密传输,或者未验证操作。

使用云上曲率RTM的知名企业与项目

FunPlus,趣加集团。中国出海品牌榜第19名,游戏领域SLG品类全球第一。
其中,
《阿瓦隆之王》(King of Avalon):曾连续半年以上进入Apple AppStore 游戏收入榜前五的SLG类型游戏。全面使用云上曲率RTM作为信令传输和IM聊天消息渠道。单项目消息量日峰值超越240亿条/天。
《火枪纪元》(Guns of Glory):同样曾连续半年以上进入Apple AppStore 游戏收入榜前五的SLG类型游戏,同样全面使用云上曲率RTM作为信令传输和IM聊天消息渠道。

Century Games:点点互动/世纪创游。旗下多款知名游戏的聊天系统采用云上曲率RTM。

其它相关的SDK

云上曲率基于RTM,面向游戏和社交娱乐行业,还分别有不带UI界面的IM Lib SDK,以及带有可自定义界面的 IM Kit SDK。
相对于 RTM SDK 而言,IM Lib 和 IM Kit 更加上层,封装更加面相IM和社交娱乐行业。接如何使用更加简便。我们后续会推出基于IM Lib 和 IM Kit 的文章和教程。

IM Kit 效果参考:

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(四):服务端搭建与总结_第4张图片

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(四):服务端搭建与总结_第5张图片

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(四):服务端搭建与总结_第6张图片

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(四):服务端搭建与总结_第7张图片

千亿级IM独立开发指南丨全球即时通讯全套代码4小时速成(四):服务端搭建与总结_第8张图片

7. 其他的版本与方案

本篇计划后续推出Android/Kotlin版本,以及Go的服务端版本。
RTC部分正在考虑合适的demo形式。
此外,RTM是信令层面的SDK,我们后续也将推出IM Lib和IM Kit 层面的demo和文章。
相关后续文档,请关注云上曲率官方账号。

你可能感兴趣的:(IM系统4小时速成,c++,实时互动,linux)