(eblog)7、即时群聊开发,聊天记录等

小助手 1年前 ⋅ 1310 阅读

群聊开发

今天我们就来完成一个聊天室的功能。在课程中,我们讲过一个例子springLayIM,例子也是集成了layim实现了网页版的聊天功能。我们这次写的没这么复杂,我们主要学会前后端交互的过程即可。

技术选型:

  • 前端layim、websocket
  • 后端t-io websocekt版

首先我们先来吧layim的界面先运行起来。layim是layui的一个付费模块,首先我们把layui的静态资源包放到static中,关于layim,因为不是完全开源的产品,所以我就不给出具体的包了。

layim官网

引入layIM

首先引进相关layim模块插件,这个插件不是开源的,线上使用需要进行捐赠,同学们如果需要在商业中使用最好进行捐赠哈。

然后按照官网给出的例子,我们来搞个最简单的hello word。在这之前,我们先要获取一下插件,原则上我们应该通过捐赠形式获取,不过只为了学习,所以就直接从网络上搜索了一个,相关的js如下:

图片

然后我们看官方文档说明:https://www.layui.com/doc/modules/layim.html

图片

这段js我们放到哪呢,我想的效果是这样的,在首页的正下方有个群聊按钮,点击之后可以打开群聊窗口进行群聊。所以为了所有页面都能聊天,我把js写在了全局模板中

  • templates/inc/layout.ftl

    $(function () {
        layui.use('layim', function(layim){
            //先来个客服模式压压精
            layim.config({
                brief: true //是否简约模式(如果true则不显示主面板)
                ,min: true
            }).chat({
                name: '客服姐姐'
                ,type: 'friend'
                ,avatar: 'http://tp1.sinaimg.cn/5619439268/180/40030060651/1'
                ,id: -2
            });

            layim.setChatMin(); //收缩聊天面板
        });
    });

这段js的效果如下,我们来分析一下:layim.config表示进行初始化配置,brief: true表示简约模式,只有一个聊天窗口,.chat是声明并打开一个聊天窗口,layim.setChatMin(); 表示收缩聊天面板。 图片

点击之后的效果:

图片

ok,上面是我们最简单的一个聊天窗口已经可以展示出来了,不过现在还没有功能,还不能相互聊天,接下来我们会给每个窗口一个身份,然后进行相互聊天。

t-io集成websocket

上面我们引入layim之后就可以看到一个聊天窗口了。我们来看下我们要去完成什么功能:

需求

  • 实现一个无区别群聊的功能
  • 登录网站的用户就能开始群聊
  • 未登录用户可以匿名聊天

功能

  • 聊天信息群发
  • 历史消息记录
  • 匿名聊天

接下来我们一步步去完成。

首先我们后端集成一下t-io与websocket。回顾一下学习过的t-io课程的内容。

首先t-io使用的helloword:

图片

(初始化服务器)

图片

(客户端与服务端通讯流程)

然后集成t-io之后要去实现的消息逻辑处理:

图片

常用类说明:

图片

通过以上内容的回顾我们知道了几个比较关键的类,也是我们再初始化启动t-io服务的几个关键类。下面我们去把t-io去启动起来。

在t-io中,要启动服务我们最终调用的代码是:tioServer.start();

因为我们这次要实现的功能是t-io集成websocket。而t-io为我们已经帮我们集成了一套代码,帮我们省去了协议升级等步骤,这样我我们就不需要像我们课程里的例子这样去手动写很多升级协议等代码了。

可以先来感受一下官网给出的例子tio-websocket-showcase:

图片

例子中我们需要例会http包下的代码,这是类似于springmvc的用法,t-io也帮我们写了一套mvc代码。那这个项目怎么启动呢?

很多简单:直接运行ShowcaseWebsocketStarter的main方法即可启动项目,然后访问localhost就能看到界面了,大家在做我们作业项目之前,可以先去看下这个基本聊天的功能是怎么实现的,然后我们再回头做自己的项目就会觉得简单很多了。

我们接着我们作业:集成t-io的websocket。因为是直接有一套集成框架,所以我这里直接引入最新版本:https://mvnrepository.com/artifact/org.t-io/tio-websocket-server

<!-- https://mvnrepository.com/artifact/org.t-io/tio-websocket-server -->
<dependency>
    <groupId>org.t-io</groupId>
    <artifactId>tio-websocket-server</artifactId>
    <version>3.2.5.v20190101-RELEASE</version>
</dependency>

然后结合刚刚看的项目例子,我们先来说明一下几个比较重要的类

  • IWsMsgHandler(握手、消息处理类)
    • 这个是消息处理的接口,包括握手时、握手完成后、消息处理等方法
    • 会在org.tio.websocket.server.WsServerAioHandler中调用,而这个类实现了ServerAioHandler。里面有我们熟悉的decode、encode、handler三个方法。
  • WsServerStarter(ws服务启动类)
    • 针对ws,tio封装了很多涉及到的东西,从而使配置更加简便,他的start方法中可以看出其实就是我们熟悉的tioServer.start。
  • ServerGroupContext(配置类)
    • 这个我们就比较熟悉了,服务端的配置类,可以配置心跳时间等。

以上就是我们需要清除的3个类。有了这3个类之后我们就可以启动我们的服务,进行ws的连接了。

那么好,我们来写个配置类。com.homework.im.config.ImServerAutoConfig

首先需要指定端口,所以加上一下代码,大家在yml上自行加上配置。

@Value("${im.server.port}")
private Integer imPort;
  • application.yml
im:
  server:
    ip: 127.0.0.1
    port: 9326

然后我们回顾一下需要配置的东西:

  • 第一步、初始化IWsMsgHandler、ServerGroupContext,然后配置到WsServerStarter,调用start方法启动服务。
  • 第二步、因为消息类型有很多(发送消息、心跳消息、加好友等),所以我们还需要初始化一下消息类型对应的处理类容器Map,这样我们在处理消息的时候直接根据类型就能找到对应的消息处理类调用就行了。

好了,有了想法之后我们就去实现一下。

  • com.example.config.ImServerAutoConfig
@Slf4j
@Data
@Configuration
@Order(value = Integer.MAX_VALUE)
public class ImServerAutoConfig {

    @Value("${im.server.port}")
    private Integer imPort;

    @Bean
    public ImServerStarter imServerStarter() {

        try {
            ImServerStarter imServerStarter = new ImServerStarter(imPort);
            imServerStarter.start();

            //初始化消息处理器工程
            MsgHandlerFactory.init();

            log.info("---------> im server started !");
            return imServerStarter;

        } catch (IOException e) {
            log.error("im server 启动失败~~", e);
        }
        return null;
    }

}

大家先看下上面的代码,我做了两步,首先我定义了一个自定义类ImServerStarter,把端口传进去,这里面的逻辑大概就是初始化 IWsMsgHandler、ServerGroupContext,然后配置到WsServerStarter等。然后start()其实就是完成了初始化之后调用tio的wsServerStarter.start()。这样我们就完成了刚才说的第一步。 再来看下自定义类ImServerStarter的具体代码:

@Slf4j
public class ImServerStarter {
    private ImWsMsgHandler imWsMsgHandler;
    private WsServerStarter wsServerStarter ;
    private ServerGroupContext serverGroupContext;
    public ImServerStarter(int imPort) throws IOException {
        imWsMsgHandler = new ImWsMsgHandler();
        wsServerStarter = new WsServerStarter(imPort, imWsMsgHandler);
        serverGroupContext = wsServerStarter.getServerGroupContext();
        serverGroupContext.setHeartbeatTimeout(1000 * 60);
    }
    public void start() throws IOException {
        this.wsServerStarter.start();
    }
}

看起来是不是挺简单的,我可调整了几次写出来的,哈哈。如果不熟悉tio的用法同学回去回顾一下我们的课程内容,tio的用法不算难,主要的类就几个,底层都帮我们封装好了,所以用起来挺简单。 然后看下第二步的内容:

//初始化消息处理器工程
MsgHandlerFactory.init()

这里是初始化消息处理器,我们说过消息可能会有多种类型,对应不同的处理器,所以这里我们就先初始化一下,放在一个静态的map里面,以后想要处理器的话就直接调用这个工厂的方法从map容器里面获取就可以了。

  • com.example.im.handler.MsgHandlerFactory
/**
 * 1、消息处理器初始化工程
 * 2、根据类型获取消息处理器
 */
public class MsgHandlerFactory {

    private static boolean isInit = false;
    private static Map<String, MsgHandler> handlerMap = new HashMap<>();

    /**
     * 得预先初始化消息处理器
     */
    public static void init(){
        if(isInit){ return; }

        handlerMap.put(Constant.IM_MESS_TYPE_CHAT, new ChatMsgHandler());
        handlerMap.put(Constant.IM_MESS_TYPE_PING, new PingMsgHandler());

        isInit = true;
    }

    public static MsgHandler getMsgHandler(String type) {
        return handlerMap.get(type);
    }

}

从上面我们可以看到有几个一个接口MsgHandler ,两个实现类ChatMsgHandler、PingMsgHandler,调用getMsgHandler(String type)就能返回具体的实现。MsgHandler 接口有个方法handler,所以所有实现类都实现这个方法即可(就是消息处理逻辑)。 处理逻辑我们后面再说,经过上面的步骤,我们已经可以初始化启动tio的服务了,并且消息处理器也有了,写来的任务我们就是让前端ws和后端集成起来,然后去处理对应的消息(后面大部分都是围绕ImWsMsgHandler开发)。

实现前端与后端联调

1、前后端建立ws连接

后端是使用的ws版本的tio,前端我们也用ws来建立连接。回顾一下我们以前说websocket的课程内容,前端建立ws连接很简单:

var socket = new WebSocket('ws://localhost:9326');

就搞定了,然后socket有几个回调方法,分别是socket.onopen、socket.onmessage、socket.onclose等,也是我们主要去使用的几个方法。 之前我们已经写了一个layim的简单例子能把聊天窗口渲染出来了,我们在layim.config初始化配置完了之后,建立ws连接。因为等会还设计到心跳、断开重连等逻辑处理,所以我需要调整一下js的结构,让js更加符合我们java的思想。

首先在新建并引入im.js这个js文件,然后在js里面我新建了一个tio类,tio.ws内部类,先按照这个说法吧,不知道对不对。= {} 表示这个定义一个对象。

if (typeof(tio) == "undefined") {
    tio = {};
}
tio.ws = {};

然后我们往tio.ws里面写方法,以后需要用到的地方就可以新建一个tio.ws,然后调用对应方法即可。是不是很符合面对对象思想。哈哈

//这个相当于构造函数吧
tio.ws = function ($, layim) {
}

这里面我们主要有几个方法

  • 建立连接方法,同时监听ws消息接受、关闭、异常等
  • 心跳、断开重连
  • 发送消息
  • 初始化聊天窗口数据(比如窗口标题头像等、离线聊天记录)

我们一一讲解,首先来看建立连接:

  • static/js/im.js

图片

其实围绕着ws的几个方法展开的逻辑。layim.getMessage是layim的接口,这个接口让我们把对话json信息显示下窗口上。

2、心跳与断开重连

每次发送消息我们都会记录一下最后发送消息的时间,用于心跳时候如果未发送消息太久就心跳一下表示活着,这样ws连接不会被服务端销毁。

当服务端出现故障时候,前端会一直自动尝试重连,会回调onclose,所以我们只需要在onclose方法im重连即可。

然后来看下ping的逻辑

图片

其实就是启动一个定时器,然后发送时间久没发送消息就自动发送心跳包,注意指定心跳消息类型是pingMessage,这样服务端就能获取到对应处理器处理了。

发生ws异常时候我们需要删除这个定时器,重连时候再开启心跳。

3、发送消息

然后我们来看下发送消息的方法

this.sendChatMessage = function(res) {
    //监听到上述消息后,就可以轻松地发送socket了
    this.socket.send(JSON.stringify({
        type: 'chatMessage' //随便定义,用于在服务端区分消息类型
        ,data: res
    }));
}

可以看到,其实就是使用socket.send方法,注意消息类型chatMessage。res里面是要发送的消息。 什么时候触发?这个得涉及到layim的接口了。我们等下看。

4、完成前端整个流程的初始化

上面我们在tio.ws这个对象里面定义了好多方法,建立连接、心跳、发送消息等等。那么我们现在就去使用一下这个对象。

  • static/js/index.js
layui.use('layim', function (layim) {
    var $ = layui.jquery;
    //初始化layim
    layim.config({
        brief: true //是否简约模式(如果true则不显示主面板)
        ,voice: false
        ,members: {
            url: '/chat/getMembers'
        },
        chatLog: layui.cache.dir + 'css/modules/layim/html/chatlog.html'
});
    //建立ws链接,监听消息
    var tiows = new tio.ws($, layim);
    tiows.connect();
    //初始化群聊离线信息
    tiows.initHistroyMess();
    //打开群聊窗口并初始化个人信息
    tiows.openChatWindow();
    //发送消息
    layim.on('sendMessage', function(res){
        tiows.sendChatMessage(res);
    });
});

以上代码中,我们初始化layim、建立ws连接、初始化群聊消息、个人消息,然后打开群聊窗口,最后再监听layim的发送消息回调方法,刚才说的什么时候调用发送消息就是这里触发。

5、数据初始化

上面我们还调用了初始化数据的相关接口,这里补充一下。layim对格式是有一定要求的。大家可以看下文档:

包括个人信息,临时窗口的信息等。

this.initChatData = function () {
    $.ajax({
        url: '/chat/getMineAndGroupData',
        async: false,
        success: function (data) {
            mine = data.data.mine;
            group = data.data.group;
        }
    });
}

这里涉及到的后端接口有两个:

  • 获取用户信息和群聊信息getMineAndGroupData
  • 获取群成员名单getMembers

我们需要安装官方文档说明的格式返回对应的json数据,所以我对应做了一些数据封装类(VO),比如:ImUser等。

  • com.example.controller.ChatController
@RestController
@RequestMapping("/chat")
public class ChatController extends BaseController {

    @Autowired
    ChatService chatService;

    @GetMapping("/getMineAndGroupData")
    public Result getMineAndGroupData(HttpServletRequest request) {

        //获取用户信息
        ImUser user = chatService.getCurrentImUser();

        //默认群
        Map<String, Object> group = new HashMap<>();
        group.put("name", "社区群聊");
        group.put("type", "group");
        group.put("avatar", "http://tp1.sinaimg.cn/5619439268/180/40030060651/1");
        group.put("id", Constant.IM_DEFAULT_GROUP_ID);
        group.put("members", 0);

        return Result.succ(MapUtil.builder()
                .put("mine", user)
                .put("group", group)
                .map());
    }
}

这里面的ImUser user = chatService.getCurrentImUser();是获取当前用户信息,分为两种情况,已登录和未登录,所以我做了区分,匿名用户给了一个随机id,然后保存在session中,这样匿名会话会一直是id直到窗口关闭。

  • com.example.service.impl.ChatServiceImpl
@Override
public ImUser getCurrentImUser() {
    AccountProfile profile = (AccountProfile)SecurityUtils.getSubject().getPrincipal();

    ImUser user = new ImUser();

    if(profile != null) {
        user.setId(profile.getId());
        user.setAvatar(profile.getAvatar());
        user.setUsername(profile.getUsername());
        user.setMine(true);
        user.setStatus(ImUser.ONLINE_STATUS);
    } else {
        user.setAvatar("http://tp1.sinaimg.cn/5619439268/180/40030060651/1");

        // 匿名用户处理
        Long imUserId = (Long) SecurityUtils.getSubject().getSession().getAttribute("imUserId");
        user.setId(imUserId != null ? imUserId : RandomUtil.randomLong());

        SecurityUtils.getSubject().getSession().setAttribute("imUserId", user.getId());

        user.setSign("never give up!");
        user.setUsername("匿名用户");
        user.setStatus(ImUser.ONLINE_STATUS);
    }

    return user;

}

然后还有一个获取在线用户的接口主要是是个方法:chatService.findAllOnlineMembers(),用户握手完成协议升级并上线之后,我们就会把当前用户的信息保存到redis中,所以这个方法,我们就是从redis中获取出来即可,我们在后面会讲到。 以上就是前端与后端联调的过程。启动服务器,打开首页,就能看到聊天窗口,F12看到已经建立ws链接。发送消息后,我们在com.homework.im.server.ImWsMsgHandler#onText方法中可以接收到消息。

下面我们针对接受消息并处理来展开说明

后端消息处理

在初始化tio服务的时候我们就说过,消息处理、握手等会围绕着com.example.im.server.ImWsMsgHandler这个类来进行。这里面有个几个方法是我们需要注意的:

  • 握手前handshake
  • 握手后onAfterHandshaked
  • 字符消息处理onText

我们现在要做的是个一个群聊功能,我们来梳理一下需求:

  • 用户登录成功之后,我们把用户id绑定到tio的通道中,然后提醒全部用户该用户上线
  • 一个用户发送消息时候,所有的用户都可以接收到

好,我们来看下,绑定用户id到tio这个功能可以在握手前或者握手后,提醒全部用户上线应该握手后。

接收到用户发送消息,我们会找到对应处理器,然后处理完毕之后群发给所有用户

ok,搞定。

我们来看下代码:

  • 握手前

图片

  • 握手后,这里还有点问题,聊天窗口上显示不出系统消息,有点bug。。。。

图片

  • 消息处理

图片

关于消息类型:

图片

发送消息是chatMessage。

我们找到处理器:

  • com.example.im.handler.impl.ChatMsgHandler
@Slf4j
@Component
public class ChatMsgHandler implements MsgHandler {

    @Override
    public void handler(String data, WsRequest wsRequest, ChannelContext channelContext) {
        ChatInMess chatMess = JSONUtil.toBean(data, ChatInMess.class);

        log.info("--------------> {}", chatMess.toString());

        ImUser mine = chatMess.getMine();
        ImTo to = chatMess.getTo();
        ImMess responseMess = new ImMess();
        responseMess.setContent(mine.getContent());
        responseMess.setAvatar(mine.getAvatar());
        responseMess.setMine(false);//是否我自己发的信息(自己不需要发送给自己)
        responseMess.setUsername(mine.getUsername());
        responseMess.setFromid(mine.getId());
        responseMess.setTimestamp(new Date());
        responseMess.setType(to.getType());
        responseMess.setId(Constant.IM_DEFAULT_GROUP_ID);//群组的id

        ChatOutMess chatOutMess = new ChatOutMess(Constant.IM_MESS_TYPE_CHAT, responseMess);

        String responseData = JSONUtil.toJsonStr(chatOutMess);
        log.info("群发消息 =========> {}", responseData);

        //用tio-websocket,服务器发送到客户端的Packet都是WsResponse
        WsResponse wsResponse = WsResponse.fromText(responseData, "utf-8");

        ChannelContextFilter filter = new ChannelContextFilterImpl();
        ((ChannelContextFilterImpl) filter).setCurrentContext(channelContext);

        //群发
        Tio.sendToGroup(channelContext.groupContext, Constant.IM_GROUP_NAME, wsResponse, filter);

        //保存群聊信息
        ChatService chatService = (ChatService) SpringUtil.getBean("chatService");
        chatService.setGroupHistoryMsg(responseMess);

    }
}

其实还算简单对吧~无非就是数据的拼凑。需要注意的是,这个有个通道过滤器,也就是说本人发送的消息不会群发给本人,而是前端直接展示的,所以我们需要写了一个过滤器:

  • com.example.im.handler.filter.ChannelContextFilterImpl
/**
 * 通道过滤器
 */
@Data
public class ChannelContextFilterImpl implements ChannelContextFilter {

    private ChannelContext currentContext;

    /**
     * 过滤掉自己,不需要发送给自己
     * @param channelContext
     * @return
     */
    @Override
    public boolean filter(ChannelContext channelContext) {
        if(currentContext.userid.equals(channelContext.userid)) {
            return false;
        }
        return true;
    }
}

这样当通道userid和需要发送的通道userid相同时候就返回false,表示跳过。

  • 用户退出

用户退出的时候我们需要关闭通道,这样群发的时候才不会往这里面发送,同时可以统计实时在线人数。代码很简单,直接调用Tio.remove即可:

@Override
public Object onClose(WsRequest wsRequest, byte[] bytes, ChannelContext channelContext) throws Exception {
    Tio.remove(channelContext, channelContext.userid + " - 退出群聊了~");
    return null;
}

总结一下,我们用了tio的几个接口

  • 群发接口 Tio.sendToGroup
  • *绑定用户接口 *Tio.bindUser
  • *用户退出 *Tio.remove

基本上群聊我们就只会用到上面几个api,用法不复杂。下面我们来测试一下。因为用户Id的问题,所以这里还有点混乱,不过没关系,我们只要测试一下消息发送接收群发功能先。

运行项目后,打开链接来感受一下聊天室哈。

获取群成员与聊天记录

上一个版本的迭代中,我们已经完成了群成员之间的基本通信,这个版本我们来完成把获取群聊成员,群离线信息等完成它。

1、获取群成员

图片

群成员的信息获取,是监听用户的上线与下线来决定成员是否在线。

  • 上线:把该成员信息保存到redis中
  • 下线:从缓存中删除该成员信息

我们来看下layim的获取群成员需要返回的数据结构

图片

从上面我们知道,其实就是往list里面添加群成员的基本信息而已,在界面中其实只需要id,username、avatar三要素,其他不要也可以。

知道了结构之后,我们来想下这个缓存应该用什么结构呢?明显这里是个列表,可以用list、set,为了防重复,用set更好。什么情况会重复,当id,username、avatar三要素完全相同的时候就是当做是同一个元素,就不会重复。如果用户修改了头像或用户名,那么就会重复,因此,我们不能让列表被这些因素影响,只有用户的id不会修改,所以这个列表里面,我们只放id。但是只有id的话,username、avatar去哪获取呢,我们可以使用一个hash结构把用户的这些基本信息保存起来。

所以总结一下以上我们的分析,要完成这个功能我们需要用到redis的两种结构

  • set
  • hash

接下来我们来写代码把功能完成。

首先我们来写下获取群成员的接口:

/**
 * 应该从通道里面获取所有用户信息
 * @return
 */
@ResponseBody
@GetMapping("/getMembers")
public Result getMembers() {
    Set<Object> members = chatService.findAllOnlineMembers();
    log.info("获取群成员---------->" + JSONUtil.toJsonStr(members));
    return Result.ok(MapUtil.of("list", members));
}
  • 实现类:com.homework.im.service.impl.ChatServiceImpl#findAllOnlineMembers
@Override
public Set<Object> findAllOnlineMembers() {
    Set<Object> ids = redisUtil.sGet(Constant.ONLINE_MEMBERS_KEY);
    Set<Object> results = new HashSet<>();
    if(ids == null) return results;
    ids.forEach((id) -> {
        Map<Object, Object> map = redisUtil.hmget((String) id);
        results.add(map);
    });
    return results;
}

上面步骤,我们先从set列表获取ids,然后在循环获取用户信息放到列表中返回。 那信息是什么时候保存进去的,上面我们说过是监听用户的上线下线,我们来看下:

  • 上线

图片

  • 下线

图片

  • 主要逻辑

图片

上面我们运用了缓存完成了这个功能,在我们开发中,可以多运用redis缓存来帮我们完成开发,提高网站的访问速度。

2、获取聊天记录

接下来我们来获取一下群历史聊天记录。同样我们要先了解一下layim接口的设计:

图片

看下css/modules/layim/html/chatlog.html目录下的页面html,里面js有json的格式说明。

图片

首选配置一下:

图片

接下来我们分析一下:

首先我们需要把聊天记录保存起来,同样可以使用我们的redis缓存来记录。可以给个有效期,只1天有效等等。这里同样可以使用列表list来保存聊天记录。什么时候去保存呐,其实历史消息的格式和发送消息的格式是一样的,所以我们可以在发送消息的时候把消息保存到缓存中,当我们打开聊天窗口时候,从缓存中读取消息,然后再窗口上循环显示出来。

我们先写好获取和保存消息两个接口:

  • 只获取最近count条记录
  • com.example.service.impl.ChatServiceImpl#getGroupHistoryMsg
  • 什么时候调用:暴露获取历史消息接口
@Override
public List<Object> getGroupHistoryMsg(int count) {
    long length = redisUtil.lGetListSize(Constant.GROUP_HISTROY_MSG_KEY);
    return redisUtil.lGet(Constant.GROUP_HISTROY_MSG_KEY, length - count < 0 ? 0 : length - count, length);
}

图片

  • 保存消息24小时(可以灵活设计)
  • com.example.service.impl.ChatServiceImpl#setGroupHistoryMsg
  • 什么时候调用:发送信息时候
@Override
public boolean setGroupHistoryMsg(ImMess imMess) {
    return redisUtil.lSet(Constant.GROUP_HISTROY_MSG_KEY, imMess, 24 * 60 * 60);
}

图片

具体的js的代码我就不贴出来了,同学们在搭建过程中可以对比下代码改动。

效果:

图片

作业总结

ok,终于我们的课程作业已经接近尾声了,同学们也辛苦了。对于我们学习过的知识点希望大家能好好通过这个课程作业巩固一下。

我们下次再见~

-----------------------------------------------------------(完)----------------------------------------------------------

撰写人:吕一明 公众号:MarkerHub 日期:2019年8月1日


全部评论: 0

    我有话说: