JOURNAL /

关于 Shotalk 即时聊天网站的开发(注册与登录-后端篇)

关于 Shotalk 即时聊天网站的开发(注册与登录-后端篇) 封面
本文目录
  1. 一. 准备工作和项目搭建
    1. 1.1 新建 SpringBoot 项目
    2. 1.2 确定项目结构
    3. 1.3 依赖导入及配置文件编写
    4. 1.4 数据库和表的设计
  2. 二. 项目骨架搭建
    1. 2.1 开发项目逻辑结构
  3. 三. 基础代码编写
    1. 3.1 返回类和 API 状态码封装
    2. 3.2 跨域请求的解决方式
    3. 3.3 JWT (重点)
  4. 四. 核心代码编写
    1. 4.1 entity 实体类编写
    2. 4.2 mapper 层和 service 层
      1. 4.2.1 mapper 层
      2. 4.2.2 mapper 层的接口
      3. 4.2.3 service 层
    3. 4.3 Controller 核心业务逻辑开发
  5. 五. 测试
    1. 5.1 注册测试
    2. 5.2 登录测试
    3. 5.3 Redis 测试
  6. 六. 总结:

前言:注意这不是一篇类似什么手把手带你完成一个前后端分离项目的博客,这个项目基本上是我从零一个人开始开发的,所以仅以此来记录一下我的开发流程。所以说是类似一个开发日志的文章,并不是一个教学文章,但是如果里面的一些操作能给你带来一点点灵感那就再好不过了。

项目介绍:这是一个基于 SpringBoot + Vue 十分普通的一个前后端分离项目,所以登录注册等核心功能的安全验证使用了 JWT,即时聊天的核心功能实现使用了 WebSocket 的第三方组件: Socket.io
数据层使用了 MyBatis 进行数据操作。Redis 进行 token 缓存等操作。

后端技术栈
· SpringBoot
· Mybatis
· lombok
· redis
· JWT
· MySQL

一. 准备工作和项目搭建

1.1 新建 SpringBoot 项目

这个就不用说了吧,这个如果你都不会,我估计也没必要看了

1.2 确定项目结构

1.3 依赖导入及配置文件编写

pom.xml

<dependencies>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--mp-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.2.0</version>
</dependency>

<!--mp代码生成器-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.2.0</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>

<!-- MySQL 依赖-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>

<!-- lombok 实体类插件 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<!-- SpringBoot 测试工具 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>

<!-- -->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.10</version>
</dependency>

<!-- Junit 单元测试 -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.1</version>
<scope>test</scope>
</dependency>


<!-- jwt 依赖-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

<!-- lo4j 日志工厂 -->
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>1.2.16</version>
<scope>compile</scope>
</dependency>

<!-- 阿里 json 工具 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.75</version>
</dependency>

application.yml(SpringBoot 配置文件编写)

server:
port: 后端运行端口号
spring:
# MySql 配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/your database name?user=root&password=root&useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=GMT
username: your username
password: your password
# redis 配置
redis:
host: localhost
port: your port name

1.4 数据库和表的设计

这里随便建个表就好(有用户名密码还有邮箱这几个字段就 ok 了),因为只是测试登录和注册功能,完整项目的表后续更新即时聊天核心功能实现的时候会展示的。

二. 项目骨架搭建

2.1 开发项目逻辑结构

完整的项目结构

图接上图

三. 基础代码编写

3.1 返回类和 API 状态码封装

以下是定义通用返回对象,这个网上有很多封装很好的可以拿来用的,这个一般都是统一规范定义的,建议自己有能力整合一套,因为这玩意是配套的基本上,要用就要用一套。(我这个是定义在 api 包里的)

CommonResult 通用返回类

/**
* @author: 清风摇翠
* @Date: 2022/3/23 19:38
* @Description:通用返回对象
*/
public class CommonResult<T> {
private long code;
private String message;
private T token;

protected CommonResult() {

}

protected CommonResult(long code, String message, T token) {
this.code = code;
this.message = message;
this.token = token;
}

/**
* 成功返回结果
* @param data 获取的数据
*/
public static <T> CommonResult<T> success(T data) {
return new CommonResult<T>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data);
}

/**
* 成功返回结果
* @param data 获取的数据
* @param message 提示信息
*/
public static <T> CommonResult<T> success(T token, String message) {
return new CommonResult<T>(ResultCode.SUCCESS.getCode(), message, token);
}

/**
* 失败返回结果
*
* @param errorCode 错误码
*/
public static <T> CommonResult<T> failed(IErrorCode errorCode) {
return new CommonResult<T>(errorCode.getCode(), errorCode.getMessage(), null);
}

/**
* 失败返回结果
*
* @param message 提示信息
*/
public static <T> CommonResult<T> failed(String message) {
return new CommonResult<T>(ResultCode.FAILED.getCode(), message, null);
}

/**
* 失败返回结果
*/
public static <T> CommonResult<T> failed() {
return failed(ResultCode.FAILED);
}

/*
* 用户名或密码校验失败返回结果
*
* @param errorCode 错误码
*/
public static <T> CommonResult<T> usernameorpassworderroe(IErrorCode errorCode) {
return new CommonResult<T>(errorCode.getCode(), errorCode.getMessage(), null);
}

/*
* 用户名或密码校验失败返回结果
*
* @param message 提示信息
*/
public static <T> CommonResult<T> usernameorpassworderroe(String message) {
return new CommonResult<T>(ResultCode.USERNAME_OR_PASSWORD_ERROR.getCode(), message, null);
}

/*
* 用户名或密码校验失败返回结果
*
*/
public static <T> CommonResult<T> usernameorpassworderroe() {
return usernameorpassworderroe(ResultCode.USERNAME_OR_PASSWORD_ERROR);
}

/*
* 用户名不存在失败返回结果
* @param errorCode 错误码
*/
public static <T> CommonResult<T> accountpwdnotexist(IErrorCode errorCode) {
return new CommonResult<T>(errorCode.getCode(), errorCode.getMessage(), null);
}


/*
* 用户信息不存在返回失败结果
* @param message 错误信息
* */
public static <T> CommonResult<T> accountpwdnotexist(String message) {
return new CommonResult<T>(ResultCode.ACCOUNT_PWD_NOT_EXISTS.getCode(), message, null);
}

/*
* 用户信息不存在失败返回结果
* */
public static <T> CommonResult<T> accountpwdnotexist() {
return accountpwdnotexist(ResultCode.ACCOUNT_PWD_NOT_EXISTS);
}


/**
* 参数验证失败返回结果
*/
public static <T> CommonResult<T> validateFailed() {
return failed(ResultCode.VALIDATE_FAILED);
}

/**
* 参数验证失败返回结果
*
* @param message 提示信息
*/
public static <T> CommonResult<T> validateFailed(String message) {
return new CommonResult<T>(ResultCode.VALIDATE_FAILED.getCode(), message, null);
}

/**
* 未登录返回结果
*/
public static <T> CommonResult<T> unauthorized(T data) {
return new CommonResult<T>(ResultCode.UNAUTHORIZED.getCode(), ResultCode.UNAUTHORIZED.getMessage(), data);
}

/**
* 未授权返回结果
*/
public static <T> CommonResult<T> forbidden(T data) {
return new CommonResult<T>(ResultCode.FORBIDDEN.getCode(), ResultCode.FORBIDDEN.getMessage(), data);
}

/*
* Token 校验失败返回结果
* @Param errorCode 错误码
* */
public static <T> CommonResult<T> tokenerror(IErrorCode errorCode) {
return new CommonResult<T>(errorCode.getCode(), errorCode.getMessage(), null);
}

/*
* Token 校验失败返回结果
*@Parm message 错误信息
* */
public static <T> CommonResult<T> tokenerror(String message) {
return new CommonResult<T>(ResultCode.TOKEN_ERROR.getCode(), message, null);
}

/*
* Token 校验失败返回结果
* */
public static <T> CommonResult<T> tokenerror() {
return tokenerror(ResultCode.TOKEN_ERROR);
}

public long getCode() {
return code;
}

public void setCode(long code) {
this.code = code;
}

public String getMessage() {
return message;
}

public void setMessage(String message) {
this.message = message;
}

public T getToken() {
return token;
}

public void setData(T token) {
this.token = token;
}
}

IErrorCode(接口)

/**
* @author: 清风摇翠
* @Date: 2022/3/23 19:42
*/
public interface IErrorCode {
long getCode();
String getMessage();
}

ResultCode(类)

使用枚举的好处是把状态码和状态信息结合到了一起,当然不使用枚举类用静态成员变量也行,状态码信息需要单独封装。

/**
* @author: 清风摇翠
* @Date: 2022/3/23 19:39
* 枚举了一些常用API操作码
*/
public enum ResultCode implements IErrorCode {
SUCCESS(200, "操作成功"),
FAILED(500, "操作失败"),
VALIDATE_FAILED(1001, "参数检验失败"),
UNAUTHORIZED(401, "暂未登录或token已经过期"),
TOKEN_ERROR(1003, "Token 错误"),
FORBIDDEN(403, "没有相关权限"),
USERNAME_OR_PASSWORD_ERROR(405, "用户名或密码错误"),
DATA_Not_Exist_ERROR(603, "数据不存在"),
DATA_ADD_ERROR(604, "数据添加异常"),
ACCOUNT_PWD_NOT_EXISTS(1002, "用户名或者密码不存在");


private long code;
private String message;

private ResultCode(long code, String message) {
this.code = code;
this.message = message;
}

@Override
public long getCode() {
return code;
}

@Override
public String getMessage() {
return message;
}
}

3.2 跨域请求的解决方式

产生跨域请求的原因还有同源策略建议自行百度

CorsConfig(配置类)

/**
* @author: 清风摇翠
* @Date: 2022/3/23 19:41
* @Description:跨域请求解决
*/
@Configuration
public class CorsConfig {
private CorsConfiguration buildConfig() {
System.out.println("跨域工具类测试");
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOrigin("*"); // 1
corsConfiguration.addAllowedHeader("*"); // 2
corsConfiguration.addAllowedMethod("*"); // 3
return corsConfiguration;
}
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", buildConfig()); // 4
return new CorsFilter(source);
}
}

3.3 JWT (重点)

这里简单说一下 JWT 的验证流程:

  • 首先,前端通过Web表单将自己的用户名和密码发送到后端的接口。这一过程一般是一个HTTP POST请求。建议的方式是通过SSL加密的传输(https协议),从而避免敏感信息被嗅探。

  • 后端核对用户名和密码成功后,将用户的id等其他信息作为JWT Payload(负载),将其与头部分别进行Base64编码拼接后签名,形成一个JWT(Token)。形成的JWT就是一个形同lll.zzz.xxx的字符串。 token head.payload.singurater

  • 后端将JWT字符串作为登录成功的返回结果返回给前端。前端可以将返回的结果保存在localStorage或sessionStorage上,退出登录时前端删除保存的JWT即可。

  • 前端在每次请求时将JWT放入HTTP Header中的Authorization位。(解决XSS和XSRF问题) HEADER

  • 后端检查是否存在,如存在验证JWT的有效性。例如,检查签名是否正确;检查Token是否过期;检查Token的接收方是否是自己(可选)。

  • 验证通过后后端使用JWT中包含的用户信息进行其他逻辑操作,返回相应结果。

JwtUtils 工具类

这个基本上也是统一规范定义的,可以根据项目的大小来自己定义所需的方法,但是基本的几个流程是不会变的。

public class JwtUtils {
public static final String jwtToken = "你自己定义的密钥";


public static String createToken(int userId) {
Map<String, Object> claims = new Hashtable<>();
claims.put("userId", userId);

JwtBuilder jwtBuilder = Jwts.builder()
// 签名算法,密钥为 jwtToken
.signWith(SignatureAlgorithm.HS256, jwtToken)
// body 数据,需要保证唯一
.setClaims(claims)
// 签发时间设定
.setIssuedAt(new Date())
// 设置一天的有效时间
.setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000));
String token = jwtBuilder.compact();
return token;
}

// 检查 Token 是否合法
public static Map<String, Object> checkToken(String token) {
try {
Jwt parse = Jwts.parser().setSigningKey(jwtToken).parse(token);
return (Map<String, Object>) parse.getBody();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
// 测试 token
@Test
public void test() {
// 测试 token 是否成功生成
String token = JwtUtils.createToken(100);
System.out.println(token);

// 测试生成 token 是否合法
Map<String, Object> map = checkToken(token);
System.out.println(map.get("userId"));
}
}

四. 核心代码编写

4.1 entity 实体类编写

这一层的编写一般是按照数据库中定义的字段去编写相应的实体类,我前面说过,由于这个是登录注册业务的测试所以数据库中只有几个字段,所以想对应的实体类也会比较简单

User (类)

/**
* @author: 清风摇翠
* @Date: 2022/4/11 12:25
* @Description:用户实体类
*/

@Data
public class User {
private int id;

private String username;

private String password;

private String email;
}

4.2 mapper 层和 service 层

4.2.1 mapper 层

userMapper.xml
这里是所有关于用户的操作

<mapper namespace="com.alexjoker.mapper.UserMapper">
<!-- 查询用户全部信息 -->
<select id="findUser" resultType="com.alexjoker.entity.User">
select * from user where username = #{username} and password = #{password}
</select>

<!--通过 id 查找用户 -->
<select id="findUserById" resultType="com.alexjoker.entity.User">
select * from user where id = #{id}
</select>

<!-- 注册用户信息用户名唯一性校验 -->
<select id="findUserByName" resultType="com.alexjoker.entity.User">
select * from user where username = #{username}
</select>

<!-- 注册用户信息邮箱唯一性校验 -->
<select id="findUserByEmail" resultType="com.alexjoker.entity.User">
select * from user where email = #{email}
</select>
</mapper>

RegisterMapper.xml
这里是用户注册的操作

<mapper namespace="com.alexjoker.mapper.RegisterMapper">
<insert id="Register" parameterType="com.alexjoker.entity.User">
insert into user(username, password, email) values (#{username}, #{password}, #{email})
</insert>
</mapper>

4.2.2 mapper 层的接口

UserMapper

/**
* @author: 清风摇翠
* @Date: 2022/4/11 12:26
*/

@Mapper
public interface UserMapper {
User findUser(String username, String password);

User findUserById(int id);

User findUserByName(String username);

User findUserByEmail(String email);
}

RegisterMapper

这里需要注意一下:mapper文件中的update,delete,insert语句是不需要设置返回类型的,它们都是默认返回一个int ,所以把返回值类型改成 int。

@Mapper
public interface RegisterMapper {
int Register(String username, String password, String email);
}

4.2.3 service 层

整体项目结构(注意在设计时尽量降低其业务耦合度)

LoginService 接口

/**
* @author: 清风摇翠
* @Date: 2022/4/11 12:24
* @Description:登录功能实现
*/

public interface LoginService {
// 登录
CommonResult login(User user);

// 退出登录
CommonResult logout(String token);
}

LoginServiceImpl 实现类

/**
* @author: 清风摇翠
* @Date: 2022/4/11 12:25
* @Description 关于登录业务的实现
*/
@Service("LoginService")
public class LoginServiceImpl implements LoginService {

@Autowired
private UserService userService;

@Autowired
private RedisTemplate<String, String> redisTemplate;

@Override
public CommonResult login(User user) {
/*
* 1. 检查参数是否合法
* 2. 根据用户名和密码去 user 表中查询是否存在
* 3. 如果不存在 登录失败
* 4. 如果存在,使用 jwt 生成 token 返回给前端
* 5. token 放入 redis 中,token:user 信息,设置过期时间
* ( 登录认证的时候 先认证 token 字符串是否合法,去 redis 确认是否存在
* */

String username = user.getUsername();
String password = user.getPassword();
if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) {
return CommonResult.validateFailed("参数不合法");
}
User sysUser = userService.findUser(username, password);
if (sysUser == null) {
return CommonResult.accountpwdnotexist("用户名或密码不存在");
}
String token = JwtUtils.createToken(sysUser.getId());
// 将 token 存入 redis 中并设置过期时间
redisTemplate.opsForValue().set("TOKEN_" + token, JSON.toJSONString(sysUser), 1, TimeUnit.DAYS);
return CommonResult.success(token);
}

@Override
public CommonResult logout(String token) {
redisTemplate.delete("TOKEN_" + token);
return CommonResult.success("退出登录成功");
}
}

RegisterService 接口

/**
* @author: 清风摇翠
* @Date: 2022/5/1 21:36
* @Description:注册功能实现
*/
public interface RegisterService {
CommonResult register(User user);
}

RegisterService 实现类

/**
* @author: 清风摇翠
* @Date: 2022/5/1 21:37
* @Description:注册功能接口实现
*/
@Service("RegisterService")
public class RegisterServiceImpl implements RegisterService {
@Autowired
private RegisterMapper registerMapper;


@Autowired
private UserService userService;


@Override
public CommonResult register(User user){
/*
* 1. 检查参数是否合法
* 2. 根据用户名和密码去 user 表中查询是否存在
* 3. 如果存在,说明用户名或者邮箱已存在,重新注册
* 4. 如果不存在进行注册业务
* */
String username = user.getUsername();
String email = user.getEmail();
String password = user.getPassword();
if (StringUtils.isBlank(username) || StringUtils.isBlank(email) || StringUtils.isBlank(password)) {
return CommonResult.validateFailed("参数不合法");
}

// 以下是关于用户信息唯一性的校验
User sysUsername = userService.findUserByName(username);
if (sysUsername != null) {
return CommonResult.validateFailed("用户名已存在");
}
User sysEmail = userService.findUserByEmail(email);
if (sysEmail != null) {
return CommonResult.validateFailed("该邮箱已存在");
}
// 注册业务实现
registerMapper.Register(username, password, email);

return CommonResult.success("注册成功");
}
}

UserService 接口

/**
* @author: 清风摇翠
* @Date: 2022/4/13 16:25
* @Description:用户接口
*/

public interface UserService {
User findUserById(int id);

User findUser(String username, String password);

User findUserByName(String username);

User findUserByEmail(String email);

}

UserServiceImpl 实现类

/**
* @author: 清风摇翠
* @Date: 2022/4/13 16:25
* @Description:用户接口实现
*/

@Service("UserService")
public class UserServiceImpl implements UserService {
@Autowired
UserMapper userMapper;

@Autowired
TokenService tokenService;

@Override
public User findUserById(int id) {
return userMapper.findUserById(id);
}

@Override
public User findUserByName(String username) {
return userMapper.findUserByName(username);
}

@Override
public User findUserByEmail(String email) {
return userMapper.findUserByEmail(email);
}

@Override
public User findUser(String username, String password) {
return userMapper.findUser(username, password);
}

TokenServcie 接口

/**
* @author: 清风摇翠
* @Date: 2022/4/13 18:48
* @Description:token 校验接口
*/
public interface TokenService {
User chekToken(String token);
}

TokenServiceImpl 实现类

/**
* @author: 清风摇翠
* @Date: 2022/4/13 18:48
* @Description:token 校验接口实现
*/

@Service("TokenService")
public class TokenServiceImpl implements TokenService {

@Autowired
private RedisTemplate<String, String> redisTemplate;

// 校验 token 合法性
@Override
public User chekToken(String token) {
if (StringUtils.isBlank(token)) {
return null;
}
Map<String, Object> objectMap = JwtUtils.checkToken(token);
if (objectMap == null) {
return null;
}
String userJson = redisTemplate.opsForValue().get("Token_" + token);
if (StringUtils.isBlank(userJson)) {
return null;
}
User user = JSON.parseObject(userJson, User.class);
return null;
}
}

4.3 Controller 核心业务逻辑开发

LoginController 登录业务类

/**
* @author: 清风摇翠
* @Date: 2022/5/1 21:35
* @Description
*/


@ResponseBody
@RestController
public class RegisterController {

@Autowired
RegisterService registerService;

@RequestMapping(value = "/register")
public CommonResult register(@RequestBody User user) {
return registerService.register(user);
}

}

LogoutController 退出登录业务类

/**
* @author: 清风摇翠
* @Date: 2022/5/2 21:46
* @Description
*/

@RestController
public class LogoutController {
@Autowired
LoginService loginService;


@GetMapping(value = "/logout")
public CommonResult logout(@RequestHeader("Authorization") String token) {
return loginService.logout(token);
}

}

RegisterController 注册业务类

/**
* @author: 清风摇翠
* @Date: 2022/5/1 21:35
* @Description
*/


@ResponseBody
@RestController
public class RegisterController {

@Autowired
RegisterService registerService;

@RequestMapping(value = "/register")
public CommonResult register(@RequestBody User user) {
return registerService.register(user);
}

}

五. 测试

ApiPost 进行后端测试

5.1 注册测试

这里有个小问题,我 success 返回的应该是 data 但这里是 token 貌似是我返回类有个地方写串行了,不过影响不大。

5.2 登录测试

5.3 Redis 测试

六. 总结:

OK! 到这里后端基本上就写完了,里面有一点还可以再进行优化的地方,后面在说前端的时候会详细说的,其实总的来说这个项目难到我的不是后端,因为 SpringBoot 这一套还是比较对我而言的还是比较熟练的,前端才是最头痛的一件事,下一篇会详细说明我是在 Vue 里面坐牢的。

写在最后:

其实最最开始的时候,也就是我在考虑这个项目怎么做的时候,我有想过要不要以此来写一篇类似手把手的教学博客,但是后来放弃了,主要原因是真的没有什么时间去弄(主要是懒)其次就是这个项目我有给过行内的朋友们看过,真的是做的非常一般,所以我觉得真的没什么必要,而且这个世界上的小白太多了,我没有义务去手把手的教会他们,所以接下来关于这个项目的几篇文章,真的只是记录一下我的思路罢了,另外国内的抄袭风气实在是太严重了,从某园抄到某N。

说一下我做这个项目的理由吧:首先就是对自己技术栈的一个锻炼,其次就是我真的很想开发一个比较有意思的项目,外加现在市面上的这种前后端分离项目太成熟了,成熟到什么地步呢,可以这么说,项目和框架体量大的,可以大到初学者一眼看不出相互逻辑的,并且网上很少有将一个业务从大项目中分离开去给你解释的,所以对于初学者是非常的不友好。也算是对于自己开发的一种记录吧

最后吐槽一下:别看我就是简简单单贴了几行代码上去说说了思路,由于这个是真正能给用户用到的项目,所以很多写法不能想你在学校里面写大作业一样随便糊弄一下就过去了,看着简单,但这背后我付出的努力和遇到的困难是没有经历过的人是没有办法想到的。所以不要轻易的就去嘲笑一个人的成果不管是大是小。

分享关于 Shotalk 即时聊天网站的开发(注册与登录-后端篇)

交流与讨论

评论由 Valine / LeanCloud 提供,点击后连接第三方服务。

搜索文章