单聊做完了,接下来就是群聊的实现了。
在做群聊之前,我们还是要先分析一下实现群聊应该需要实现哪些接口。
群聊接口分析
1.创建群聊
首先我们第一个要实现的接口,就是创建群聊了。毕竟想要有群,必须创建群才行。那么创建群,需要传递什么参数呢?我们需要有一个GroupCreateModel
,里面传递一些创建群必须有的参数,比如:
群名称、群描述、群头像地址,还有就是一个群肯定是要有成员的,所以还要有一个Set用来存储群成员的信息,这里我们取群成员id即可。因为创建群实际上关联到我们后台的两个表,tb_group,tb_group_member;
我们一方面要将群信息存到tb_group,一方面也需要将群成员信息存储到tb_group_member中去,这里需要注意的是,创建者在存到成员信息表的时候,其权限要赋值为创建者的权限,并不是普通成员的权限。
还有一点十分关键,就是创建群聊后,我们需要给群里成员推送一条消息,说某某某加入了群聊。
存储和推送这两个部分是我们创建群聊的核心内容。当然在创建完群聊后,推送前,我们也要加一些容错逻辑,比如可能服务端出错,创建群聊失败(比如传递进来的群成员id有问题,就可能导致我们创建了一个没有用户的群)。
那我们的逻辑就可以清晰的表述一下了:
通过model中set查找用户--->创建群聊(存储群和成员)--->规避无效群错误--->推送消息
看一下实现代码:
/**
* 创建群聊
*
* @param model 基本参数
* @return 群信息
*/
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public ResponseModel<GroupCard> create(GroupCreateModel model) {
if (!GroupCreateModel.check(model)) {
return ResponseModel.buildParameterError();
}
//创建者
User creator = getSelf();
model.getUsers().remove(creator.getId());
if (model.getUsers().size() == 0) {//等于0说明这个群只有创建者,所以出错
return ResponseModel.buildParameterError();
}
if (GroupFactory.findByName(model.getName()) != null) {
return ResponseModel.buildHaveNameError();
}
//判断群成员信息是否正确
List<User> users = new ArrayList<>();
model.getUsers().stream().forEach(userId -> {
User user = UserFactory.findById(userId);
if (user != null) {
users.add(user);
}
});
if (users.size() == 0) {
return ResponseModel.buildParameterError();
}
Group group = GroupFactory.create(creator, model, users);
if (group == null) {
return ResponseModel.buildServiceError();
}
//拿到创建者信息,规避服务端错误
GroupMember createMember = GroupFactory.getMember(creator.getId(), group.getId());
if (createMember == null) {
return ResponseModel.buildServiceError();
}
//拿到群的成员,给所有的群成员发送信息,已经被添加到群的信息
Set<GroupMember> members = GroupFactory.getMembers(group);
if (members == null) {
return ResponseModel.buildServiceError();
}
members = members.stream().filter(groupMember -> {
return !groupMember.getUserId().equalsIgnoreCase(createMember.getId());
}).collect(Collectors.toSet());
//开始发起推送
PushFactory.pushJoinGroup(members);
return ResponseModel.buildOk(new GroupCard(createMember));
}
除了一些容错逻辑外,这里有三个部分是核心内容:创建群、拿到群里所有的成员、推送消息。
1.创建群,我们看看创建群如何创建。
在此之前,我们还是要先分析一下。创建一个群,我们要拿到GroupCreateModel里,群的三个关键性信息:群name,群描述、群头像;然后构建一个Group,将其存入数据库。接着构建群成员列表,将群成员列表存到群里。那么最关键的信息:群的id是如何拿到的呢?
看看我们的Group数据表:
@Id
@PrimaryKeyJoinColumn
//主键生成存储的类型为UUID,自动生成
@GeneratedValue(generator = "uuid")
//把uuid的生成器定义为uuid2,uuid2是常规的UUID toString
@GenericGenerator(name = "uuid",strategy = "uuid2")
//不允许更改,不允许为null
@Column(updatable = false,nullable = false)
private String id;
通过uuid自动生成。
public static Group create(User creator, GroupCreateModel model, List<User> users) {
return Hib.query(session -> {
Group group=new Group(creator,model);
session.save(group);//存储到数据库中如此简洁高效
GroupMember owner=new GroupMember(creator,group);
//群主
owner.setPermissionType(GroupMember.PERMISSION_TYPE_ADMIN_SU);
session.save(owner);
users.stream().forEach(user -> {
GroupMember groupMember=new GroupMember(user,group);
session.save(groupMember);
});
return group;
});
}
所以的我们群创建过程就如同我们分析的那般,这里群创建者的权限要赋值一下,他跟普通成员不是一个级别的。
2.拿到一个群的所有成员
我们创建完群后,可能有服务器端错误,导致我们创建了一个空群,所以我们需要拿到创建好的群里所有的成员,看看我们创建的是否是无效的群聊。
这里getMember,我们想一下如何去拿:
首先就是通过group的id去查GroupMember表,这样拿出来的应该是一个List<GroupMember>
,确切的说,应该是一个Set<GroupMember>
,拿到这个set集合后,我们就可以容错判断了。
不过老师实现的时候,采用了一种更加简洁有效的方法:
public static GroupMember getMember(String memberId, String groupId) {
return Hib.query(session -> (GroupMember)session.createQuery(
"from GroupMember where userId=:userId and groupId =:groupId")
.setParameter("userId",memberId)
.setParameter("groupId",groupId)
.setMaxResults(1)
.uniqueResult()
);
}
通过传入创建者的id和群id,去群成员表中查询有没有创建者信息即可。
3.推送消息
推送消息这块,我们还是通过个推来实现。我们要给所有的成员都推送一条消息,通过上面的getMember我们已经拿到了群里所有的成员列表,接下来正好可以利用这个来给每个用户推送消息。我们推送的时候,设置的receiver是User,我们拿到的是GroupMember,所以通过GroupMember构建一个User,这个方法也得写入GroupMember。为什么不用上面我们在UserFactory.findById里查找的的List<User>
呢?个人认为是规避一些无谓的错误,比如可能有人是无效的等。
可以看到我们的GroupMember表中存储了User信息,加载方式使急加载。所以我们上面查询到的GroupMember中有User信息,我们getUser即可。下面我们看看如何推送消息:
public static void pushJoinGroup(Set<GroupMember> members) {
//个推中的发送者
PushDispatcher dispatcher=new PushDispatcher();
//发送的消息要存到历史表中
List<PushHistory> histories=new ArrayList<>();
members.stream().forEach(member->{
User receiver=member.getUser();
if (receiver==null) return;
GroupMemberCard memberCard=new GroupMemberCard(member);
String entity=TextUtil.toJson(memberCard);
PushHistory history=new PushHistory();
history.setEntityType(PushModel.ENTITY_TYPE_ADD_GROUP_MEMBERS);
history.setEntity(entity);
history.setReceiver(receiver);
history.setReceiverPushId(receiver.getPushId());
histories.add(history);
//构建一个消息Model
PushModel pushModel=new PushModel();
pushModel.add(history.getEntityType(),history.getEntity());
dispatcher.add(receiver,pushModel);
});
Hib.queryOnly(session -> {
histories.stream().forEach(history->{
session.saveOrUpdate(histories);
});
});
//不要忘了提交,否则不推送
dispatcher.submit();
}
这里将GroupMemberCard作为消息实体推送给每一位用户,里面存储了群的id、群成员的id(自动生成,和userId不同)、成员的权限等信息。
将每一条推送都构造一个历史消息,存到历史推送消息表中,最后通过我们封装的PushDispatcher的sumbit方法,推送消息。
好,第一个创建群的方法简要分析算是完成了。
2.搜索群
有了创建群,肯定要配上对应的搜索群了。
搜索群的逻辑,个人简要分析的话。首先返回的肯定是一个List<GroupCard>
,然后这个群的话,我们需要传入一个name,这个又涉及到了我们的模糊匹配,通过模糊匹配去数据库查询到我们的群,最后返回到客户端。所以核心逻辑在于我们的模糊查询上。
@GET
@Path("/search/{name:(.*)?}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public ResponseModel<List<GroupCard>> search(@PathParam("name") @DefaultValue("") String name) {
User self = getSelf();
List<Group> groups = GroupFactory.search(name);
if (groups != null && groups.size() > 0) {
List<GroupCard> groupCards = groups.stream().map(group -> {
GroupMember member = GroupFactory.getMember(self.getId(), group.getId());
return new GroupCard(group, member);
}).collect(Collectors.toList());
return ResponseModel.buildOk(groupCards);
}
return ResponseModel.buildOk();
}
可以看到查到群列表后,我们判断是否无效的群:群为空或者成员个数不对头。
然后通过Group构造GroupCard返回给客户端。
可以看到我们的核心逻辑,果然在search里面:
/**
* 查找群,模糊匹配
* @param name
* @return
*/
public static List<Group> search(String name){
if (Strings.isNullOrEmpty(name))
name="";
final String searchName="%"+name+"%";
return Hib.query(session -> {
return (List<Group>)session.createQuery("from Group where lower(name) like :name")
.setParameter("name",searchName)
.setMaxResults(20)//至多查找二十个群
.list();
});
}
就是一个模糊查询,至多查找二十个群,也没有毛病。(如果要搜索全部的话,倒也是可以,不过这肯定也是个耗时操作了)
3.拉取自己所有的群聊
有了能搜索所有群的方法,自然少不了拉取自己群的方法了。首先确定返回值是List<GroupCard>
,我们需要的参数是一个用户的id,不过这里有一点是我们添加了一个日期参数,因为可能一个用户有很多群,我们可以传入一个时间节点,拉取这个时间点后所加入的群聊。因为用户的信息,我们可以通过拦截器的getSelf拿到。所以这个id信息是不需要我们传的了。这个时间点我们也可以设置个默认值,用户可传可不传。
这里课程实现的方法也挺有意思的,就是获取一个用户当前的GroupMember信息,因为一个群里面用户只有一列GroupMember信息。所以通过一个Set<GroupMember>
列表集合,就可以构造一个List<GroupCard>
,其中的实现就是通过GroupMember去构造一个GroupCard。因为什么可以这么构造呢?我们看看GroupCard里的承载的信息:
public GroupCard(GroupMember member) {
this(member.getGroup(), member);
}
public GroupCard(Group group) {
this(group, null);
}
public GroupCard(Group group, GroupMember member) {
this.id = group.getId();
this.name = group.getName();
this.desc = group.getDescription();
this.picture = group.getPicture();
this.ownerId = group.getOwner().getId();
this.notifyLevel = member != null ? member.getNotifyLevel() : 0;
this.joinAt = member != null ? member.getCreateAt() : null;
this.modifyAt = group.getUpdateAt();
}
GroupMember中:
Group中持有Group信息,且是急加载,所以GroupMember信息一旦加载,其内部也就有Group信息了。这样的数据表设计也没毛病,一个群成员连自己群的信息都不知道,他难道不会怀疑自己进了个假的群?那么list的实现也就明了了;
/**
* 拉取自己所在的群聊;
* 不传时间,就返回自己最近一段时间的群聊
* @param date
* @return
*/
@GET
@Path("/list/{date:(.*)?}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public ResponseModel<List<GroupCard>> list(@DefaultValue("") @PathParam("date") String date) {
User self = getSelf();
LocalDateTime dateTime = null;
if (!Strings.isNullOrEmpty(date)) {
try {
dateTime = LocalDateTime.parse(date);
} catch (Exception e) {
e.printStackTrace();
dateTime = null;
}
}
//拿到一个人的所有GroupMember,代表每个群里的个人信息
Set<GroupMember> members = GroupFactory.getMembers(self);
if (members == null || members.size() == 0) {
return ResponseModel.buildOk();
}
final LocalDateTime finalDateTime = dateTime;
List<GroupCard> groupCards = members.stream()
.filter(groupMember ->//时间为空,或者在给出的时间之后做过修改的群
finalDateTime == null || groupMember.getUpdateAt().isAfter(finalDateTime))
.map(GroupCard::new)
.collect(Collectors.toList());
return ResponseModel.buildOk(groupCards);
}
4.获取一个群的信息
获取一个群的信息,通过传入groupId,去后台数据库查找相应的群信息。
这个群得是发起请求的这个人已经加入的。还有一点要明确,Group表中是没有群成员信息的!群成员信息所在地是GroupMember,也就是群成员表。所以要想查这个群,我们就需要去GroupMember表中查找是否有该成员的信息,有则可以通过GroupMember来构造一个GroupCard。
/**
* 获取一个群的信息
* @param id
* @return
*/
@GET
@Path("/{groupId}")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public ResponseModel<GroupCard> getGroup(@PathParam("groupId") String id) {
if (Strings.isNullOrEmpty(id)){
return ResponseModel.buildParameterError();
}
User self=getSelf();
GroupMember member=GroupFactory.getMember(self.getId(),id);
if (member==null){//想要获取一个群的信息,自己必须是这个群的成员
return ResponseModel.buildNotFoundUserError(null);
}
return ResponseModel.buildOk(new GroupCard(member));
}
通过GroupFactory.getMember来获取该成员的GroupMember信息,有则说明有这个群,则能拿到群的信息。否则说明这个id的群没有此用户,没有权限拿到这个群的信息。
/**
* 获取一个群的成员,通过此来构造一个GroupCard
* @param memberId
* @param groupId
* @return
*/
public static GroupMember getMember(String memberId, String groupId) {
return Hib.query(session -> (GroupMember)session.createQuery(
"from GroupMember where userId=:userId and groupId =:groupId")
.setParameter("userId",memberId)
.setParameter("groupId",groupId)
.setMaxResults(1)
.uniqueResult()
);
}
5.群的成员列表
能拿到群的信息,自然也需要提供方法来拿到所有群成员的信息了。要传的参数自然还是groupId,还是去GroupMember表去查,传入groupId,将所有匹配的GroupMember信息拿到就对应的群里的各个群成员了。当然,我们再此之前, 还是要通过GroupFactory.getMember方法来判断当前用户是否是此群的成员,是否有权限拿到所有的成员信息。
/**
* 拉取一个群的所有成员,自己必须是这个群的成员
*
* @param groupId
* @return
*/
@GET
@Path("/{groupId}/member")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public ResponseModel<List<GroupMemberCard>> members(@PathParam("groupId") String groupId) {
User self=getSelf();
Group group=GroupFactory.findById(groupId);
if (group==null){
return ResponseModel.buildNotFoundGroupError(null);
}
GroupMember selfMember=GroupFactory.getMember(self.getId(),groupId);
if (selfMember==null){//不是此群的成员
return ResponseModel.buildNoPermissionError();
}
//所有的的成员
Set<GroupMember> members=GroupFactory.getMembers(group);
if (members==null){
return ResponseModel.buildServiceError();
}
List<GroupMemberCard> memberCards=members.stream()
.map(GroupMemberCard::new)
.collect(Collectors.toList());
return ResponseModel.buildOk(memberCards);
}
所以这里的核心逻辑就是在这个GroupFactory.getMembers中了,通过这个方法来拿到一个群所有的GroupMemberCard.那么这个方法的内部实现,其实我们也能猜想出来,就是传入groupId,去查询GroupMember表,将匹配的成员列表拿到,然后通过GroupMember表去构造对应的GroupMemberCard即可。
/**
* 在GroupMember表中查找一个群的所有成员
* @param group
* @return
*/
public static Set<GroupMember> getMembers(Group group) {
return Hib.query(session -> {
List<GroupMember> groupMembers= session.createQuery(" FROM GroupMember WHERE group=:group ")
.setParameter("group",group)
.list();
return new HashSet<>(groupMembers);
});
}
因为GroupMember中持有groupId和Group,所以通过传入group也可以拿到相关的GroupMember。可见Hibernate的强大,不光能匹配简单字符集,还能匹配实体类信息。
6.加入群方法
群的创建、群的查找、查找自己加入的群、获取一个群的信息、获取一个群成员列表的方法都有了,我们也要提供一个至关重要的方法,就是加入群聊。毕竟一个群的成员不能只是刚创建时的几个人,一个群发扬光大的靠的是更多的新鲜血液。其实加入一个群的方法还是比较复杂点的:
首先强调一点:加入群的这个方法,针对的是群创建者或者管理员,一个人想要加入群,得找这个两个身份的人去拉进去。这也是这个接口的缺陷吧,我们更想要的是申请加群,然后推送给管理员或者群主同意之后,即可进群。这个我们后续还是要改造一下,先加个todo,后面提供一个扩建接口join实际上就是实现这个功能。
首先我们得提供一个GroupMemberAddModel,将需要拉进群的人的信息传递进入,好让我们进行加群等逻辑判断:
@Expose
private Set<String> users=new HashSet<>();
public Set<String> getUsers() {
return users;
}
public void setUsers(Set<String> users) {
this.users = users;
}
public static boolean check(GroupMemberAddModel model){
return !(model.users==null
||model.users.size()==0
);
}
可以看到里面就一个属性,就是users。通过这个Set集合,将用户的id传递进来,然后我们去User表中查找这些用户用户是否合法。
接下来就是此方法里的逻辑了:我们得判断这个群是否有效,接下来判断当前请求用户是否是这个群的创建者或者管理员,接着拿到我们需要拉进群的这些人的信息,然后操作数据库将这些人写入GroupMember表中,这是新成员的,此外还需要老成员的列表,为什么要呢?因为我们要给老成员推送一条消息:某、某、某进入了群聊,我们将这些信息加入的人的信息推个老成员,还要给新成员推送一条,你已被加入什么什么群聊,推送的内容就是新加入的所有成员的GroupMember,里面可以去到群信息。不过个人觉的没必要将其他人信息也推给他吧,毕竟他也不需要新加入的其他人的个人信息。
@POST
@Path("/{groupId}/member")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public ResponseModel<List<GroupMemberCard>> memberAdd(@PathParam("groupId") String groupId,
GroupMemberAddModel memberAddModel) {
// TODO: 2020/2/2 改造成自己申请进群的方法 --即底部的join方法
if (Strings.isNullOrEmpty(groupId)|| !GroupMemberAddModel.check(memberAddModel)){
return ResponseModel.buildParameterError();
}
User self=getSelf();
memberAddModel.getUsers().remove(self.getId());
if (memberAddModel.getUsers().size()==0){//群里只有一个人,也是参数错误
return ResponseModel.buildParameterError();
}
Group group=GroupFactory.findById(groupId);
if (group==null){//m没找到群,
return ResponseModel.buildNotFoundGroupError(null);
}
//拿到自己在群里的信息
GroupMember selfMember=GroupFactory.getMember(self.getId(),groupId);
//想要加人,就必须是群管理员或群主,普通成员没有权限
if (selfMember==null || selfMember.getPermissionType()==GroupMember.NOTIFY_LEVEL_NONE){
return ResponseModel.buildNoPermissionError();
}
//拿到已有的群成员
Set<GroupMember> oldMembers=GroupFactory.getMembers(group);
//拿到成员的id信息
Set<String> oldMemberUserIds=oldMembers.stream()
.map(GroupMember::getGroupId)
.collect(Collectors.toSet());
List<User> insertUsers=new ArrayList<>();
memberAddModel.getUsers().stream().forEach(userId -> {
User user=UserFactory.findById(userId);
if (user!=null &&!oldMemberUserIds.contains(userId)) {
insertUsers.add(user);
}
});
if (insertUsers.size()==0){
return ResponseModel.buildParameterError();
}
//进行添加操作
Set<GroupMember> insertMembers=GroupFactory.addMembers(group,insertUsers);
if (insertMembers==null){
return ResponseModel.buildServiceError();
}
//转换
List<GroupMemberCard> insertCards=insertMembers.stream()
.map(GroupMemberCard::new)
.collect(Collectors.toList());
//通知两部曲
//1.通知新增的成员,自己被加入群聊
PushFactory.pushJoinGroup(insertMembers);
//2.通知老的成员,有新人加入
PushFactory.pushGroupMemberAdd(oldMembers,insertCards);
return ResponseModel.buildOk(insertCards);
}
这个方法的体积还是比较大的,个人觉得可以吧,把那个什么,把这几个Factory类抽离一下,通过interface来声明对service提供的方法,然后将具体实现交给各个Factory来。将底层的数据库操作交给更底层的类来操作,这样是否可以降低耦合性?起码方法里有什么改动的方法的话,不需要动这几个Service类。是否符合开闭原则??待思考…
上面的实现里只有addMembers之前没有提过:
/**
* 给群添加新成员
* @param group
* @param insertUsers
* @return
*/
public static Set<GroupMember> addMembers(Group group,List<User> insertUsers){
return Hib.query(session -> {
Set<GroupMember> members=new HashSet<>();
insertUsers.stream().forEach(user -> {
GroupMember member=new GroupMember(user,group);
session.save(member);
//这里并没有从数据库中查询,所以关联的外键:userId,groupId可能拿不到
members.add(member);
});
return members;
});
}
这里通过group和user,来构造新加入的人的群成员信息,然后存储到数据库中。
底下的推送方法,推给老成员的消息,就是通过表里,将每一条消息存到PushHistory表中,跟之前创建群的推送类似,也暂且不提了。
实现的方法就是这六个,实际上还有一些扩展方法,比如,个人通过可以申请加入群的方法,join。也可以提供一个修该群成员信息的方法,比如修改一个成员的权限:改为管理员,或者说修改此人在群里的备注。这些在跟完课程做一下比较好,一方面完善app的功能。一方面也真正的自己做些新东西,而不是只停留在理解懂其实现的程度上,毕竟跟这个课程的目的,是我们也要能做出如此的东西。
接口做完了,我们需要测试一下接口是否有问题,没有问题就可以进入到前台界面开发了:
个人测试没毛病。
群聊关于服务端的东西暂且这么多,我们接下来看IM前台开发七之群聊前台实现。