文章目录
1.背景
即时通讯(Instant messaging,简称IM),允许两人或多人使用网路即时的传递文字或多媒体信息。把现实世界的信息转变为网络数据,以实现人与人,Host与Host,在网络世界中,“面对面”交流,增加交流效率和交流体验。
国内的QQ、Wechat,国外的MSN等,都是知名的即时通信软件。现在,各大App也有自己简单通讯功能,比如客服服务,即时通讯的Instant,以增加用户体验满意度。
2.框架设计
2.1 主流技术
- 直接使用TCP/UDP,建立TCP连接,维持"用户—服务器"的长连接,配合UDP,发送消息,响应速度快,但是需要大量资源,实现难度大;
- WebSocket,用于网页端长连接通信,可看做Web的长连接,但是使用场景有限;
- 消息推送:采用消息队列,推送用户通知或即时信息,方案简单。
2.2 Restful技术方案
为什么选用Restful?框架设计灵活,实现成本小(运维成本需另考虑),即时通讯是一个状态敏感的场景,里面很多场景可以作为许多微服务项目解决方案参考。
SpringBoot作为服务后台,MongoDB作为用户和数据保存,Redis作为缓存数据库,protobuf作为消息通信编码协议。
在Restful无状态框架中,解决”实时”敏感状态保存,虽然不是IM最佳解决方案,却是一个问题无聊但是实现有意思的场景。
NoSQL真的适合IM吗,有哪些缺点,Redis是"万能"的O(1)吗。Talking is cheap. Let’s coding.
3.软件依赖
运行环境:freebsd 11,JDK8
软件 | 版本 |
---|---|
springboot | 2.7.4 |
redis | 5.0 |
mongodb | 4.4 |
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.19.3</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
4. 用户数据库设计
先贴上V1的简单用户表吧,MongoDB临时加字段洒洒水啦。
// 用户角色
public enum UserRole {
COMMON_USER,
GRAY_USER
}
// 用户表
@Getter
@Setter
@Document(collection = "user")
@Accessors(chain = true)
public class User {
// 用户ID
@Id
private String uid;
// 注册时间
@NotNull
private Date regTime;
// 用户名称
@Length(min = 3, max = 18)
private String name;
// 密码
@Length(min = 6, max = 18)
private String password;
// 电邮
@Email
private String email;
// 手机号
@NotBlank
@Pattern(regexp = "^1(3|4|5|7|8)\\d{9}$", message = "phone num error")
private String phone;
// 头像信息
@URL
private String headUrl;
// 用户角色
private UserRole status;
// 用户好友
private List<String> friends;
// 用户群组
private List<String> groups;
}
这里就能看出,MongoDB对于结构确定的数据存放的缺点,比如用户好友的存放,Mysql分库分表,会使用FriendShip的owner的映射表,而MongoDB存放在一个bson,查找方便,但是添加使用比较麻烦,需要将所有friends全部取出,查看是否存在好友判断也是需要遍历,因此这里会结合redis尽可能高效实现。
4. 注册和登录功能
4.1 MongoRepository使用
MongoDB对于简单操作,仅需用extends MongoRepository,使用时可以按照条件添加简单接口,是个很好的轮子。
比如:按照用户和比吗查找用户:findUserBy + “Name”,定义为函数User findUserByName(String name),即可直接使用。
@Configuration
public class MongoConfig {
@Autowired
private MongoDatabaseFactory mongoDatabaseFactory;
@Autowired
private MappingMongoConverter mappingMongoConverter;
@Bean
public MongoTemplate mongoTemplate() {
mappingMongoConverter.setTypeMapper(new DefaultMongoTypeMapper(null));
return new MongoTemplate(mongoDatabaseFactory, mappingMongoConverter);
}
}
public interface UserRepository extends MongoRepository<User, String> {
// 按照name和password查找用户
User findUserByNameAndPassword(String name, String password);
// 模糊查找,末尾加Like
List<User> findUsersByNameLike(String name);
// 判断email或phone是否已经存在
boolean existsUserByEmailOrPhone(String email, String phone);
}
4.2 RedisRepository使用
Redis操作可以将RedisTemplate常用操作封装在RedisRepository。
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer(Object.class);
template.setValueSerializer(serializer);
template.setKeySerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
@Slf4j
@Repository
public class RedisRepository {
private final RedisTemplate<Object, Object> redisTemplate;
public RedisRepository(RedisTemplate<Object, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void put(String key, byte[] value) {
redisTemplate.opsForValue().set(key, value);
}
public void mput(Map<String, byte[]> map) {
redisTemplate.opsForValue().multiSet(map);
}
public void set(String key, String value, Duration time) {
redisTemplate.opsForValue().set(key, value, time);
}
public String get(String key) {
Object obj =redisTemplate.opsForValue().get(key);
if (obj != null && obj instanceof String) {
return (String) obj;
}
return "";
}
public Long increment(String key) {
Long times = redisTemplate.boundValueOps(key).increment(1);
if (times != null && times == 1) {
redisTemplate.boundValueOps(key).expire(60, TimeUnit.SECONDS);
}
return times;
}
public Boolean del(String key) {
return redisTemplate.delete(key);
}
}
4.3 注册和登录
- 注册
- 用户基本信息填写正确,如name、email、phone合法
- 用户emial、phone未被注册,否则视为用户已存在
- 登录
- 用户账户和密码匹配
- 用户是未登录状态
- 用户登录尝试次数超过3次,将锁定用户1分钟
- 登录成功更新用户状态
这里用户状态、尝试超次和锁定等,均暂存于redis中。
// Service实现
@Slf4j
@Service
public class UserMgrService {
private final UserRepository userRepository;
private final RedisRepository redisRepository;
public UserMgrService(UserRepository userRepository, RedisRepository redisRepository) {
this.userRepository = userRepository;
this.redisRepository = redisRepository;
}
public boolean loginUser(String name, String password) {
final String PREFIX_LOGIN_STATUS = "LOG:";
final String LOGIN_TIME = "TIME:";
String logKey = PREFIX_LOGIN_STATUS + LOGIN_TIME + name;
if (redisRepository.increment(logKey) > 3) {
log.warn("login tried too more time");
return false;
}
User user = userRepository.findUserByNameAndPassword(name, password);
if (user == null) {
return false;
}
redisRepository.del(logKey);
final String LOGIN_STATUS = "TIME:";
String logStatusKey = PREFIX_LOGIN_STATUS + LOGIN_STATUS + name;
final String STATUS_ONLINE = "ONLINE";
if (STATUS_ONLINE.equals(redisRepository.get(logStatusKey))) {
log.warn("{} had login", name);
return false;
}
redisRepository.set(logStatusKey, STATUS_ONLINE, Duration.ofMinutes(5));
return true;
}
public boolean registerUser(User user) {
if (userRepository.existsUserByEmailOrPhone(user.getEmail(), user.getPhone())) {
log.warn("phone {} or email {} existed", user.getPhone(), user.getEmail());
return false;
}
userRepository.insert(user);
return true;
}
public List<User> searchUserByName(String name) {
return userRepository.findUsersByNameLike(name);
}
}
// Controller实现
@Validated
@RestController
@RequestMapping("/user")
public class UserMgrController {
@Autowired
private UserMgrService userMgrService;
@PostMapping("/register")
public String registerUser(@Valid User user) {
return userMgrService.registerUser(user) ? "OK" : "ERROR";
}
@PostMapping("/login")
public String loginUser(@RequestParam("name") String name,
@RequestParam("password") String password) {
return userMgrService.loginUser(name, password) ? "OK" : "ERROR";
}
@GetMapping("/search")
public List<User> searchUserByName(@RequestParam("name") String name) {
if (StringUtils.isEmpty(name)) {
return Collections.emptyList();
}
return userMgrService.searchUserByName(name);
}
}
简单注册和登录功能已实现,后续有空增加用户好友申请和群组管理,消息发送与接收,尤其是群消息的接受框架实现,对于TCP和UDP反而简单,但是对Restful是个挑战。。。