学院教务系统作业监听

写一个监听山西农业大学软件学院教务系统作业的小玩意

Posted by AchanYao on May 25, 2019

其实我也不大清楚我是以一种怎样的心态写这篇博文的。
也不大明白这篇东西流传到软件学院会造成怎样的影响。
可是我还是写了。

前言

这学期初,学院开发了一个供学生提交作业的教务系统,貌似以后还有月考系统,不过现在并没投入使用,我也不大清楚。这个教务系统有个弊端,在老师布置了作业后,并不会给学生一个反馈,会导致学生错过提交作业的时间。

对此我做了这么一个小玩意,周围不少人都劝我将它推广,但是我知道,就我腾讯云学生机实在承受不了那么大量的用户,就打算先写个文档,过段时间开源,如果有需要自己搭建吧,很遗憾我帮不了那些人。

虽然目前就我了解的情况绝大部分老师布置了作业都会提醒一下自己学生,我不知道我这东西还有没有用了,不过权当做个记录吧。毕竟我的理念一直就是尽自己所学来解放生产力。

本文不适合一无所知的小白。

技术栈

后端服务:HttpServlet
服务器:Tomcat
数据库:MariaDB
开发环境:Win10
运行环境:Ubuntu

  1. 简单的网络爬虫
    • 用来抓取API,可以不学直接用我抓取好的。
  2. 使用Java发送Http请求
    • 这篇文章中我介绍的是原生HttpURLConnection发送请求。
  3. 使用Java连接数据库
    • 我使用的数据库引擎是Mybatis
  4. Java向客户发送通知
    • 我采用的方式是Javaee内置JavaMail的邮件发送。
  5. Java Timer定时任务类

以上框架是可以跟据自己的需求换的,不过我是尽可能用原生的。也不一定非要写Javaee应用,不会Servlet的写JavaSE也行的。

简单API分离

方法

这部分是给大一学弟学妹看的,会的请直接跳过了,不想看的也可以直接跳过的。

写这篇博客的日期是19-5-25,恰逢教务系统又坏了,所以它发布还不知道得啥时候呢。

方法是打开浏览器,按下f12控制台,点击Network就可以进行简单抓包了。
对软件学院教务系统准备抓包

然后输入你的学号密码点击登录,你会发现Network下多了两个请求。
登录请求

找到我们需要的请求,单击就会看到请求的详细细节,我们需要的是它的url以及解析它的response。教务系统的response也会直接输出在控制台上,也可以直接看那个。
登录请求的详细信息

这是登录请求的响应。
登录请求的响应

同样的,我们把我的作业也抓取了。
我的作业的响应

值得注意的是,除了登录请求,其余请求都带有个cookie的,该cookie是用来识别你的身份的。作用简单说明一下:如果你新打开一个标签页,输入软件学院教务系统的地址,因为有cookie的存在,它会直接给你登录上去的。所以我们后来的请求,也都得加上cookie。
带有cookie的请求

API

HOST: http://sxau.redknot.cn:9080

用户登录

URL

/student_tmis/student_login/login/login

请求方式

POST

请求参数
name type desription
username String 学号
password String 密码
是否需要cookie

false

全部作业

URL

/student_tmis/student/homework/get_all_homework

请求方式

POST

是否需要cookie

true

逻辑分析

API分离完成,我们来捋一捋具体怎么做。我的想法是,一定时间间隔登录一次(比如说40分钟),获取到所有作业的个数。下一次登录再获取一次作业的个数,如果两次个数不一样,就说明有新的作业需要提交了。

虽然看响应有作业有被删除这状态,但是貌似还是半成品,谨慎的看官可以把有作业的条件改成本次作业个数大于上一次的作业个数。

使用Java发送Http请求

发送请求

此处以登录为案例,编写一个登录的Servlet。

首先生成一个URL对象。
new URL(http://sxau.redknot.cn:9080/student_tmis/student_login/login/login?username=" + username + "&password=" + password)
其中username和password是参数。

通过url对象生成一个连接对象。并设置该对象的属性。

        // HttpURLConnection 对象不能直接构造,需要通过openConnection();获取
        HttpURLConnection connection = (HttpURLConnection) new URL("http://sxau.redknot.cn:9080/student_tmis/student_login/login/login?username=" + username + "&password=" + password).openConnection();

        // 设置是否从HttpUrlConnection读入,默认为true
        connection.setDoInput(true);
        // 设置是否向HttpUrlConnection输出,因为这是Post请求,参数要放在http正文里,所以参数填写true
        // 默认为false
        connection.setDoOutput(true);

        // 设置connection的请求方式,默认为GET
        connection.setRequestMethod("POST");

因为后续操作都需要cookie,所以我们得获取登录的cookie,获取到的实际上是一个字符串,至于之后存入到内存中还是数据库中,各位看官请自行决定。

        Map<String, List<String>> map = connection.getHeaderFields();
        List<String> list = map.get("Set-Cookie");
        String sessionId = null;
        if (list != null) {
            StringBuilder builder = new StringBuilder();
            for (String str : list) {
                builder.append(str).toString();
            }
            sessionId = getSessionId(builder.toString());
        }

匹配Cookie的方法是这样:

    private String getSessionId(String s) {
        Pattern pattern = Pattern.compile("JSESSIONID=(.*); Path=/; HttpOnly");
        Matcher matcher = pattern.matcher(s);
        if (matcher.find())
            return matcher.group(1);
        else return null;
    }

获取connection的response。这块建议封装成方法。

        // 获取connection的输入,这里设置编码格式为utf-8;
        BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), "utf-8"));
        String inputLine;
        // 这个对象就是我们需要获取的响应json
        StringBuffer responseString = new StringBuffer();

        while ((inputLine = reader.readLine()) != null)
            responseString.append(inputLine);
        // 获取完成

获取作业的Servlet我就不再讲述了,无非是连接,然后获取响应。值得注意的是需要传cookie,我就说一下如何设置cookie吧。

String cookie = ""; // 假如说这是你登录的cookie
connection.setRequestProperty("Cookie", "JSESSIONID=" + cookie);

解析响应

我们获取到的响应都是Json类型数据,所以问题就转化为了,如何用Java解析Json数据。框架一大堆,在这里我使用的是谷歌的Gson解析Json数据。

简单功能实现只需要知道一个方法就好:

new Gson().fromJson(json, class); // 该方法是用来将json字符串转换成Java对象class类型

例如我们要将登录的响应转换成Java对象,就可以这么写。

String response = ""; // 这是你的响应json
// 我想将其解析成键值对形式的HashMap,key-value的类型可以自行指定。复杂的json不指定具体每一项的属性,Gson会自动给你匹配类型的。
HashMap<String, String> json = new Gson().fromJson(response, HashMap.class);
if (String.valueOf(json.get("code")).equals("200.0")) {
    // 登录成功
} else if (String.valueOf(json.get("code")).equals("201.0")) {
    // 账号密码错误
}
// 更多的code我没去抓,只弄了这两个。

因为是Gson自动匹配类型,所以它将code转换成了double,但是你要求的是String,所以最后就变成了字符串 “200.0”。想了解Gson如何精确解析,可以参考这里

获取作业个数可以这么写:

        String json = getResponseString(connection); // 这是你获取所有作业的响应
        HashMap<String, Object> temp = new Gson().fromJson(json, HashMap.class);
        return ((ArrayList) temp.get("model")).size(); // 将model强行转换成ArrayList类型

Java连接数据库

因为我有着好些用户,所以采用了数据库存储,如果只是给自己使用的话没必要像我一样。

设计数据库

大量用户下,给每个人都对应一套监听是不现实的。学校17届大概900号人,一个监听所需时间大概2-7s不等,视网速而定。取平均5s,5*900/60=75。75分钟,黄花菜都凉了。如果说使用多线程,我怕服务器炸╮(╯▽╰)╭。有兴趣的同学倒是可以考虑线程池,设个3个或4个任务量。应该是可以实现的。

跟据以上分析,我的解决方案是给每个班找个参照学生(这个学生最好是平日里懒得看教务系统的,当然最关键的是愿意提供教务系统密码的)。如果这个学生有新作业了,就说明整个班级的同学有新作业了。

数据库设计两张表,一张reference,一张member,reference存放学号姓名密码信息,member存放所有用户信息包括但不限于学号、姓名、邮箱。member可以不要密码。

Mybatis连接数据库

这个我是真不想讲了,大家参考文档

  1. 配置mybatis环境
  2. 建立POJO
  3. 编写接口
  4. 编写映射(mapper)文件

JavaMail发送邮件

配置JavaMail

我到网上找了找,也没发现什么好的教程,毕竟邮件是好些年前用的了,现在都是微信qq即时通讯。我也说不出个所以然来,就直接放代码讲怎么配置吧。这里我用的是qq邮箱。

import javax.mail.*;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.io.UnsupportedEncodingException;
import java.util.Properties;

public class EmailUtil {

    private static final String USER_NAME = ""; // 这里写你的邮箱
    private static final String PASSWORD = ""; // 这里写你的授权码
    private static Properties properties = System.getProperties();
    private static Session session;

    static {
        // 这里各种属性配置请参考对应邮件服务的文档
        properties.setProperty("mail.smtps.auth", "true");
        // smtp跟smtps是两个协议,简单讲可以把smtps当作smtp加强版
        // 设置smtp服务的主机
        properties.setProperty("mail.smtps.host", "smtp.qq.com");
        properties.setProperty("mail.smtp.host", "smtp.qq.com");
        // 设置smtp服务的端口
        properties.setProperty("mail.smtp.port", "465");
        // 设置邮件发送协议
        properties.setProperty("mail.transport.protocol", "smtp");

        properties.setProperty("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
        properties.setProperty("mail.smtp.socketFactory.port", "465");
        // 获取默认session对象
        session = Session.getInstance(properties, new Authenticator() {
            public PasswordAuthentication getPasswordAuthentication() {
                // 发件人邮件用户名、密码,就是你登录邮箱的密码,这也是我一直没开源的主要原因
                return new PasswordAuthentication(USER_NAME, "");
            }
        });
    }

    /**
     * 像指定成员发送
     * @param to 我自己封装的成员类型,其实只需要用到邮件地址就行
     * @param subject 邮件的主题
     * @param html 邮件主体,我这里是用html的形式,默认是文本形式
     * @throws MessagingException
     * @throws UnsupportedEncodingException
     */
    public static void sendMessage(Member to, String subject, String html) throws MessagingException, UnsupportedEncodingException {
        MimeMessage message = new MimeMessage(session); // 发送邮件的会话
        message.setFrom(USER_NAME); // 设置邮件发信人
        // 设置邮件寄给谁,第一个参数不要动,因为还有对应的抄送啥的,这里不做赘述。
        // 第二个参数是生成一个地址,参数to.getEmail()是获取邮件成员email(不可省),to.getName()是获取用户姓名(可省)
        message.addRecipient(Message.RecipientType.TO, new InternetAddress(to.getEmail(), to.getName()));

        message.setSubject(subject);
        // 设置邮件内容,文本的话不用写第二个参数
        message.setContent(html, "text/html; charset=\"utf-8\"");

        Transport transport = session.getTransport();
        transport.connect("smtp.qq.com", USER_NAME, PASSWORD);
        transport.sendMessage(message, message.getAllRecipients());
    }
}

QQ邮箱开启smtp服务

电脑上登录你的QQ邮箱,点击设置。
登录邮箱并找到设置
点击账户。
点击账户进行设置
找到SMTP服务并开启,点击生成授权码,它会给你一个授权码。
找到SMTP服务并开启
填写到代码里就行了。

QQ邮箱SMTP服务器端口号看这里

Java定时任务

以上各个组件组装在一起,就能用了,但是我们还缺少一个组装的工具。现在我们就来看看Java的Timer和TimerTask。

在 Java 中一个完整定时任务需要由 Timer、TimerTask 两个类来配合完成。 API 中是这样定义他们的,Timer:一种工具,线程用其安排以后在后台线程中执行的任务。可安排任务执行一次,或者定期重复执行。由TimerTask:Timer 安排为一次执行或重复执行的任务。我们可以这样理解 Timer 是一种定时器工具,用来在一个后台线程计划执行指定任务,而 TimerTask 一个抽象类,它的子类代表一个可以被 Timer 计划的任务。

Timer

在工具类 Timer 中,提供了四个构造方法,每个构造方法都启动了计时器线程,同时 Timer 类可以保证多个线程可以共享单个 Timer 对象而无需进行外部同步,所以 Timer 类是线程安全的。但是由于每一个 Timer 对象对应的是单个后台线程,用于顺序执行所有的计时器任务,一般情况下我们的线程任务执行所消耗的时间应该非常短,但是由于特殊情况导致某个定时器任务执行的时间太长,那么他就会“独占”计时器的任务执行线程,其后的所有线程都必须等待它执行完,这就会延迟后续任务的执行。所以要确保TimerTask的执行时间短于Timer所给运行时间。

  1. schedule(TimerTask task, Date time);
    • 安排在指定的时间执行指定的任务。
  2. schedule(TimerTask task, Date firstTime, long period);
    • 安排指定的任务在指定的时间开始进行重复的固定延迟执行。
  3. schedule(TimerTask task, long delay);
    • 安排在指定延迟后执行指定的任务。注意这个方法只执行一次。
  4. schedule(TimerTask task, long delay, long period);
    • 安排指定的任务从指定的延迟后开始进行重复的固定延迟执行。我们所需要的就是这个方法。

TimerTask

TimerTask 类是一个实现Runnable接口的抽象类,由 Timer 安排为一次执行或重复执行的任务。它有一个抽象方法 run() 方法,该方法用于执行相应计时器任务要执行的操作。因此每一个具体的任务类都必须继承 TimerTask,然后重写 run() 方法。

示例

在1s后每2s执行一次。

    public class TimerTest {
        Timer timer;

        public TimerTest(){
            timer = new Timer();
            timer.schedule(new TimerTaskTest(), 1000, 2000);
        }

        public static void main(String[] args) {
            new TimerTest();
        }
    }

    public class TimerTaskTest extends TimerTask{

        @Override
        public void run() {
            Date date = new Date(this.scheduledExecutionTime());
            System.out.println("本次执行该线程的时间为:" + date);
        }
    }

总结

要开发出一个好软件,本来就是得把自己也当成用户

还可以优化的地方

因为我现在讲的是最简单的实现,如果有兴趣的同学可以将它改改,比如说用观察者模式重构一下、Timer写个单例模式、通知成员改成接口,方便以后做微信通知、数据库做个记录、增加异常处理机制等等等等,都是可以改的。欢迎大家到评论区和我讨论。

我的应用地址

http://yaoyuxuan.cn:8090

我这个会长期维护的,直到学院有了作业通知功能。

源码地址

https://github.com/AchanYao/WorkLinstenerService

如果有用,给个星星呗。

最后的话

这篇文章如果能幸运地被学院领导看见,麻烦领导们看看就好🙈🙊🙉,这种功能如果在源码上来实现的话,其实很简单的。

请各位哥哥姐姐们看在我没干过啥坏事的份上,轻点吐槽轻点搞QAQ