前言
大三下学期,学院开设了一门名为“项目实训”的课程。一共有三种参与方式,我和我的三个舍友组队成一队,选择了第二种参与方式,也就是独立完成一个完整的项目。项目要求创新,也就是说要么立意创新,要么算法创新。我们掂量掂量了自己,发现半桶水晃啊晃的,于是果断决定立意创新。绞尽脑汁冥思苦想了两天后,一个idea终于在我们用抢红包的方式决定谁去食堂打饭时冒了出来——校园互助平台!
以下是我们项目实训的完整过程(持续更新中…)
一、开题答辩(3月11号)
直到教务老师把“答辩日期定在后天中午”的消息发在群里,我们才不得不面对这残酷的现实。正所谓“巧妇难为无米之炊”,在答辩的前一晚,我们的项目进度仅仅停留在了刚确定好了队伍的名字的程度。
意外轻松的,我们通过了答辩,但是轻松的代价是保证每日有300日活量,顺便提一句,我们一个年级300多人。
在我们看来,这几乎是一个不可能完成的任务。按照规则,如果我们最后项目没有通过,就必须选择第三种方式——花费半个暑假的时间完成项目实训。“放弃吧,直接快进到暑假吧”。我们都萌生出了这个念头。
经过了很多努力,最终目标被修改为每日完成50笔订单。虽然仍然困难,但是我们终于有了坚持做下去的动力。
二、准备阶段
1.任务分配(3月12日)
今天我们综合考虑每个队员的意愿,大致分配了任务。我的任务是完成后端的登录注册、聊天、个人信息、信誉模块,并使用Springboot提供API接口。
2.购买服务器,搭建服务器(3月13日)
为了保证最终软件产品的推广效益,我们决定开发微信小程序。要想完成这个项目,最基本的一点,就是我们需要有一个真正的服务器。
然而我们所有人都没有搭建服务器的经验。在过去我们做课设都是以自己本机作为服务器“自娱自乐”,显然,现在不行。在立项时我负责的是部分后端工作,这份任务便落在了我的身上。
对于一个像我这样的菜鸟新手来说,这个过程可以说是一步一个坑。
其中卡了我最久也最让我无语的一个坑是:当你需要打开服务器的一个端口时,不仅需要在命令窗口中打开,还需要去阿里云的控制平台手动打开防火墙!!!
即使过程很坎坷、服务器性能堪忧,但令人兴奋的是,Hellow World终于出现在了屏幕上。
就结果来说还算顺利,成功搭建了一个具备MySQL数据库、Java运行环境、Python运行环境的阿里云服务器。
下面是我搭建的具体过程,希望能给你一些参考:
https://blog.csdn.net/ListenMaybe/article/details/114761335
3.购买域名,申请备案(3月15日-3月20日)
微信小程序要求服务器必须绑定域名。所以一个合法、有效的域名也是必要的。
不幸的是,我对域名的申请也是两眼一抹黑,摸着石头过河。
购买域名倒是一个比较简单的活。阿里云平台就可以购买,几十块钱就能买到一个。
申请备案却是一个麻烦事。由于我没有申请的经历与经验,申请的表单被阿里云初审打回来好几次,几乎把能犯的错都翻了一遍,错误还都很弱智,比如身份证照片模糊、居住地址不一致、网站备注信息不合法等等,关键每次都是真人审核,打电话通知,几次尴尬地都想把电话挂了。
耗时一周,初审终于顺利通过,接下来就是等待管局的审核,为期大概半个月。
4.域名备案成功,添加ssl证书(3月22日)
管局的审核终于通过了,域名也就正式可以启用了。但是微信小程序还要求服务端提供HTTPS的安全请求。
ssl证书比较好获取,阿里云平台提供免费的ssl证书。
除了服务需要绑定ssl证书外,springboot项目也需要开启HTTPS请求。
1.配置文件中加入如下配置
2.在配置类中添加如下代码,将HTTP请求转向HTTPS。
// 监听的http请求的端口,需要在application配置中添加http.port=端口号
@Value("${http.port}")
Integer httpPort;
//正常启用的https端口 如443
@Value("${server.port}")
Integer httpsPort;
@Bean
public TomcatServletWebServerFactory servletContainer() {
TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory() {
@Override
protected void postProcessContext(Context context) {
SecurityConstraint constraint = new SecurityConstraint();
constraint.setUserConstraint("CONFIDENTIAL");
SecurityCollection collection = new SecurityCollection();
collection.addPattern("/*");
constraint.addCollection(collection);
context.addConstraint(constraint);
}
};
tomcat.addAdditionalTomcatConnectors(httpConnector());
return tomcat;
}
@Bean
public Connector httpConnector() {
Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
connector.setScheme("http");
//Connector监听的http的端口号
connector.setPort(httpPort);
connector.setSecure(false);
//监听到http的端口号后转向到的https的端口号
connector.setRedirectPort(httpsPort);
return connector;
}
3.将证书文件扔到项目根目录中
经过上述步骤后,服务器便可以接受HTTPS的请求了。
5.文档生成库JApiDocs的使用(3月30日)
我们的项目是前后端分离进行的,这就需要生成一份清晰明了的接口API文档。
现在用于Springboot生成文档的主流库是swagger,我粗粗看了一下,发现有点复杂,感觉有点头秃。这个时候我惊喜的发现了一个针对与Springboot的轻量级的库,能够十分简单方便地自动生成接口API文档,唯一要求就是规范地编写注释
需要引入的依赖如下:
<dependency>
<groupId>io.github.yedaxia</groupId>
<artifactId>japidocs</artifactId>
<version>1.4.3</version>
</dependency>
然后按照规范书写注释:
/**
* 测试文档生成
* @param testString 测试数据参数
* @return 返回测试参数
*/
@RequestMapping(value = "/docs")
@ResponseBody
public String getIn(String testString) {
return "testRerunString";
}
生成文档的代码,随便放入一个main方法中执行,Test方法中也可以:
DocsConfig config = new DocsConfig();
config.setProjectPath("E:\\"); // 项目根目录
config.setProjectName("test-project"); // 项目名称
config.setApiVersion("V1.0"); // 声明该API的版本
config.setDocsPath("E:\\"); // 生成API 文档所在目录
config.setAutoGenerate(Boolean.TRUE); // 配置自动生成
Docs.buildHtmlDocs(config);
这样生成的就是HTML格式的文档:
如果想要转换其他格式的文档,可以添加如下配置:
config.addPlugin(new MarkdownDocPlugin());
这样生成的文件夹中就不仅有网页格式的文档,还有md格式的:
你还可以自行将Markdown转换成PDF或者其他你想要的格式。
到此准备阶段正式完成。
三、正式开发阶段
1.图片的存储与访问(3月20日-25日)
头像、聊天、任务等都涉及到图片的存储和访问问题。
为了提高数据库查询的效率,我打算将图片存储到服务器硬盘上,然后数据库表中存储图片的URL。
前端传送过来的图片数据是Base64码,直接存储会出问题,需要对Base64码进行一些处理:
//处理Base64
public String fixBase64Code(String originCode){
//前台在用Ajax传base64值的时候会把base64中的+换成空格,所以需要替换回来。
String baseValue = originCode.replaceAll(" ", "+");
//去除base64值开头的声明
baseValue = baseValue.replace("data:image/jpeg;base64,", "");
baseValue = baseValue.replace("data:image/png;base64,", "");
return baseValue;
}
将处理后的Base64码转存为图片需要对其进行解密操作。我使用的是sun.misc.BASE64Decoder库的方法。
//生成图片
public boolean generateImage(String imgStr, String path) {
if (imgStr == null)
return false;
BASE64Decoder decoder = new BASE64Decoder();
try {
// 解密
byte[] b = decoder.decodeBuffer(imgStr);
// 处理数据
for (int i = 0; i < b.length; ++i) {
if (b[i] < 0) {
b[i] += 256;
}
}
OutputStream out = new FileOutputStream(path);
out.write(b);
out.flush();
out.close();
return true;
} catch (Exception e) {
return false;
}
}
经过处理、解密、存储操作后,前端传来的Base64码就顺利变成了图片存在了硬盘上。接下来需要处理的就是图片路径问题。
由于项目使用的是SpringBoot框架,集成了Tomcat,所以项目发布过程变得十分简单,只需要将项目打成jar后运行即可。但是打成jar包后的项目如何存储和访问静态图片资源呢?
经过实际测试后我发现,无论是在Windows还是Linux系统中,如果图片保存的路径为项目所在文件夹,那么实际保存路径反而去到了项目jar包所在文件夹的上一级文件夹中;如果要将图片保存在与包同一级的文件夹中,那么图片的保存路径需要填写项目根路径!!
关于如何获取项目根路径,网上的答案五花八门,我试了很多都失败了。最后我找到了一种方法,无论是在Windows还是在Linux中,无论项目是否打成了Jar包,都能正确地获取根目录的绝对路径:
//获取项目根路径
File projectFile = new File(System.getProperty("user.dir"));
关于获取的绝对路径,如果是在Windows操作系统下,那么会是类似“C:\Program Files\Java”这样的,我们将它转换为我们想要的网络URL路径,还需要进一步处理:
/**
* 将文件磁盘路径转换为URL路径,去除盘符和更换反斜杠,末尾加/
* @param originCode 文件磁盘路径
* @return
*/
public static String fixPath(String originCode){
//将反斜杠用斜杠替换
String newPath = originCode.replaceAll("\\\\", "/");
//去掉盘符
newPath = newPath.replaceAll("^[A-Z]:","");
newPath = newPath+'/';
return newPath;
}
解决了图片的存储,剩下问题就是图片的访问。
我们的项目将图片存储在了项目的外面,也就是说图片静态资源不在Springboot内嵌的Tomcat服务器中了,也就不能通过同一个端口访问了。这时自然就会想到为图片专门再开一个服务器,但是我觉得那样太麻烦了,并且会占用服务器的性能。我采用的方式是配置Springboot的静态资源映射路径:
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
/**
* 配置静态资源映射
* 后面的 “/”一定要带上
* 服务器的话一定要加file:
*/
File file = base64toImage.getImgDirFile("");
String filePath = file.getAbsolutePath();
//System.out.println("图片存储路径为==========="+filePath);
registry.addResourceHandler("/img/**")
.addResourceLocations("file:"+ MyTools.fixPath(filePath));
}
这样只要访问的资源路径是以“/images”开头的,就会被映射到服务器中存放文件的文件夹中,这样就解决了图片访问的问题。
解决了上述解码和路径问题,图片的存储和访问就没有什么障碍了。
2.一对一聊天(3月25日-31日)
我分配的任务中包含聊天,也就是实现一个一对一的及时通讯。然而,一个看似简单得不能再简单的功能却着实难倒了我。
微信小程序是一个类BS架构,也就是说一般情况下,服务器是不能主动向客户端发送消息的。但聊天是一个及时的功能,如果是传统的BS架构,前端为了保证及时性,就只能通过Ajax进行轮询或者使用Long poll技术,但是这两者都会占用较多的服务器资源,十分不合理。
问了度娘后,我才知道原来还有一种叫WebSocket的新型协议,它可以实现服务端对客户端的主动消息推送,这正是我所需要的!
Websocket是一种支持全双工通讯的协议,就和TCP和Socket一样,它可以在客户端和服务端之间建立一条平等的信道,服务器和客户端都可以主动向对方传递消息。本质上Websocket和Http是两种完全不同协议,它们之间并没有包含和被包含的关系,唯一能扯上联系的,就是WebSocket协议中客户端和服务器第一次握手时需要借助Http协议来发送连接请求。
Websocket协议只要求客户端借助Http协议向服务器发送一次连接请求,便可以在二者之间建立起一条全双工信道,既不需要Ajax轮询那样服务器被动地向客户端传递数据,也不需要像Long poll那样需要频繁地重新请求,这无疑极大地节省了服务器的资源。
具体实现如下:
在springboot配置类中开启Websocket
package pers.may.assist.config;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
import org.springframework.web.util.WebAppRootListener;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
@Configuration
public class WebsocketConfiguration implements ServletContextInitializer {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
@Override
public void onStartup(ServletContext servletContext) throws ServletException {
servletContext.addListener(WebAppRootListener.class);
servletContext.setInitParameter("org.apache.tomcat.websocket.textBufferSize","52428800");
servletContext.setInitParameter("org.apache.tomcat.websocket.binaryBufferSize","52428800");
}
}
后端服务器代码:
/**
* websocket连接接口
* @description websocket连接接口
* @author May
*/
@Controller
@ServerEndpoint(value = "/websocket/{phoneNum}") //phoneNum 从URL中获取
public class ChatSocketController {
//连接用户数
private static int onlineCount = 0;
//concurrent包的线程安全Set,用来存放每个客户端对应的MyWebSocket对象。
private static ConcurrentHashMap<String, ChatSocketController> webSocketMap = new ConcurrentHashMap<>();
//与某个客户端的连接会话,需要通过它来给客户端发送数据
private Session session;
//连接用户的手机号
private String phoneNum="";
/**
*
* 建立连接时回调的方法
*
*/
@OnOpen
public void onOpen(Session session, @PathParam("phoneNum") String phoneNum) {
this.session = session;
this.phoneNum=phoneNum;
//将连接对象加入set中
webSocketMap.put(phoneNum,this);
//在线数加1
addOnlineCount();
System.out.println("用户连接:"+phoneNum+",当前在线人数为:" + getOnlineCount());
try {
//向前端发送“连接成功的提示”
sendMessage("连接成功");
} catch (IOException e) {
System.out.println("用户:"+phoneNum+",网络异常!!!!!!");
}
}
/**
* 连接关闭回调的方法
*/
@OnClose
public void onClose() {
if(webSocketMap.containsKey(phoneNum)){
webSocketMap.remove(phoneNum);
//将连接对象从set中删除
subOnlineCount();
}
System.out.println("用户退出:"+phoneNum+",当前在线人数为:" + getOnlineCount());
}
/**
* 收到客户端消息后回调的方法
*
* @param message 客户端发送过来的消息,格式是JSON字符串
* */
@OnMessage
public void onMessage(String message, Session session) {
System.out.println("用户消息:"+phoneNum+",报文:"+message);
if(StringUtils.isNotBlank(message)){
try {
//处理收到的消息,如存数据库
}catch (Exception e){
e.printStackTrace();
}
}
}
/**
*
* 出错回调方法
*
*/
@OnError
public void onError(Session session, Throwable error) {
System.out.println("用户错误:"+this.phoneNum+",原因:"+error.getMessage());
error.printStackTrace();
}
/**
* 实现服务器主动推送
*/
public void sendMessage(String message) throws IOException {
this.session.getBasicRemote().sendText(message);
}
public static synchronized int getOnlineCount() {
return onlineCount;
}
public static synchronized void addOnlineCount() {
ChatSocketController.onlineCount++;
}
public static synchronized void subOnlineCount() {
ChatSocketController.onlineCount--;
}
}
前端客户端代码(jq):
function openSocket() {
console.log("hello");
if(typeof(WebSocket) == "undefined") {
console.log("您的浏览器不支持WebSocket");
}else{
console.log("您的浏览器支持WebSocket");
//实现化WebSocket对象,指定要连接的服务器地址与端口 建立连接
var socketUrl="https://......"+$("#myPhoneNum").val();
//将http协议替换成ws(websocket)协议
socketUrl=socketUrl.replace("https","ws").replace("https","ws");
console.log(socketUrl);
if(socket!=null){
socket.close();
socket=null;
}
socket = new WebSocket(socketUrl);
//监听连接事件
socket.onopen = function() {
console.log("websocket已打开");
//socket.send("这是来自客户端的消息" + location.href + new Date());
};
//监听获得消息事件
socket.onmessage = function(msg) {
console.log(msg.data);
};
//监听连接关闭事件
socket.onclose = function() {
console.log("websocket已关闭");
};
//监听连接错误事件
socket.onerror = function() {
console.log("websocket发生了错误");
}
}
}
function sendMessage() {
if(typeof(WebSocket) == "undefined") {
console.log("您的浏览器不支持WebSocket");
}else {
console.log("您的浏览器支持WebSocket");
let data={
}
console.log('{"toUserPhoneNum":"'+$("#toPhoneNum").val()+'","chatContent":"'+$("#content").val()+'"}');
//将对象转成JSON字符串,发送给服务器
socket.send(JSON.stringify(data));
}
}
通过上述前后端两段代码可以发现,Websocket有了相关的库,并不难使用。
在实际项目中还遇到了几个问题:
微信小程序要求服务器提供HTTPS加密的安全请求,而Websocket是通过HTTP协议请求进行第一次握手的,显然这里就行不通了。通过在网上查找后发现,Websocket提供了匹配HTTPS的协议wss,只需把ws协议换成wss协议即可。
在Springboot引入Websocket相关库后,在启动服务器或者打包的时候都会出现Test错误。网上一大堆博客我抄你,你抄我的,都说直接skip掉测试模块,纯属FP。经过实测后,在测试类上加上如下注释即可:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
如果要在Websocket类中调用服务层的提供的方法,像平常一样直接注入服务对象是行不通的,需要编写静态方法注入到静态对象中:
//静态注入ChatService
@Autowired
public void setChatService(ChatService chatService){
ChatSocketController.chatService = chatService;
}
参考文章:
https://blog.csdn.net/moshowgame/article/details/80275084.
https://blog.csdn.net/qq_35623773/article/details/87868682
3.聊天模块的实现(4月1日-5日)
有了前面Websocket的基础,聊天模块编写起来也就变得比较轻松。
聊天信息分为文字和图片两种,都存在数据库的同一张表中。如果是文字,聊天内容字段直接存储文字;如果是图片,聊天内容字段存储图片URL。设计一个type字段区分图片和文字。
对于聊天中出现的图片,用聊天双方的手机号加时间戳命名,用前面提到的方式存储。而图片的访问方式则是直接通过网络地址进行访问。
3.登录注册和个人信息模块的实现(4月10日-13日)
登录注册和个人信息模块实现比较简单,没有遇到什么障碍。
因为项目是微信小程序项目,所以登录注册时是直接通过微信获取用户的信息,如果该用户在表中则为登录;如果该用户不在表中则为注册。
因为在各个模块中都会频繁遇到获取用户头像和姓名的情况,所以专门单独写了一个接口,通过用户手机号获取头像或姓名。
还有就是信誉分的问题,在扣除用户信誉分的同时还需要在信誉记录表中添加记录。
三、中期答辩(4月22日)
中期答辩很宽松,老师主要检查项目的进展,顺利通过。
四、项目收官(5月10日)
1.新增消息提示功能(5月10日-15日)
在项目将要结束时,发现小程序拥有订阅消息提示功能。使用的是weixin小程序的订阅消息模板,并且必须是后端服务器进行申请发送。
2.更改登录方式(5月15日-20日)
为了保证用户安全,我们将登录方式改成了使用微信用户登录时自动生成的code,服务器动态去申请用户的openid,来进行用户信息的核实检验。
五、小程序提交审核(5月25日)
微信小程序从开发版到正式版需要一个审核的过程。由于我们对微信小程序的规则不太了解,导致在项目最后一环出现了比较大的问题。
我们的小程序涉及到消息的发布,所以要求是属于社交类别。但是社交需要我们是企业账号。
在弄到企业账号后,我们发现事情还没有这么简单。还要求服务器主体和账号主体一致。这就比较难实现了,我们服务器的主题是我个人 ,但账号主题是一家公司。而重新申请服务器和域名肯定是来不及了。正常审核发布这条路已经被堵死。
于是我们打算骗过审核。通过后端给予的一个bool的变量,前端根据变量控制要展示的页面是真实的还是虚假的。换句话说,我们在审核的过程中,后端设置传false,这时候审核人员看到的是“假的”页面;当项目过审发布后,我们再讲后端返回值改为“true”,这样用户看到的就是真实的页面了。
使用上述方法,小程序成功发布。