慕測平臺(簡稱mooctest),這個項目致力於編程類考試和練習的服務平臺,教師能夠輕鬆監管考試流程,學生能夠自由練習編程。系統負責編程練習的自動化評估及可視化展示,配合當下紅火的MOOC慕課課程,慕測平臺將是學生自學編程的好幫手。目前已支持的編程類型有:Java覆蓋測試,Java測試驅動編程,Python統計編程,C++編程,Jmeter性能測試,以及Android應用測試。之因此叫「mooctest」是由於「測試」是咱們的主打產品,其中Java覆蓋測試、Java Debug分析,以及Android應用測試是咱們的核心服務。咱們幫助高校的教「軟件測試」的老師便捷地組織在線考試,幫助高校的學生接觸到工業界真實的app案例,以提升學生的testing能力。css
項目概況
- mooctest於2014.8月下旬開始啓動項目,最初開發者只有2位
- 2014.11月,完成考試管理平臺的基礎建設,以及Java覆蓋測試的客戶端,開始第一輪內測
- 2014.12月,參加項目原型展現,收集第二輪內測
- 2015.1月,添加對Java覆蓋測試的考題分析功能
- 2015.3月,正式上線,與網易雲課堂合做,開設《機率論與數理統計》慕課課程,由mooctest系統提供「Python統計編程」練習
- 2015.5月,項目擴張,不斷添加新科目,Java測試驅動編程,Jmeter性能測試,以及Android應用測試也有了雛形
- 2015.7月,Android應用測試獨立成Kikbug系統,完成和mooctest系統的對接
- 2015.9月,Android應用測試與「阿里」達成合做,得到企業內測的真實app
- 2015.10月,正式在南京大學、東南大學、南京郵電大學、南通大學、大連理工等重點高校試點,做爲其「軟件測試」課程的白盒測試(以Java覆蓋測試爲例)和黑盒測試(以Android應用測試爲例)的練習和考試平臺
- 2015.11月,再次與網易雲課堂合做,開設《開發者測試》微專業課程,由mooctest系統提供「Java覆蓋測試」和「Debug調試」的練習
- 2015.12月,聯合「阿里雲測」以及TesterHome舉辦阿里雲測找 bug 大賽,圓滿落幕!
- 截至目前,mooctest平臺上已有近1萬名學生和400名老師,來自全國各地500多個高校!
項目結構
我做爲「碼農」,仍是來講說我更擅長的事,總結下這個項目的技術選型以及組織結構,以便爲從此的項目做參考。html
總體上咱們就採用了基於Java的Play Framework 1.2.7的版本,以後出的2.0.x
以上的版本是基於SCALA的,和1.x.x
徹底不是一個東西。而Play框架對「從Java學起的軟院學生」來講很是友好,比起 Struts 和 Spring 省去了不少繁瑣的xml配置和Annotation配置。綜合學習成本和項目定位,Play框架是性價比很高的選擇。前端
目錄結構
後臺部分java
- lib/ 存放各類外部jar包
- conf/ Play框架配置文件的目錄
- application.conf 項目系統設置:debug設置、session設置、server設置、數據庫設置等,也可存放自定義的系統級變量設置
- routes 路由(url)配置
- messages.en 多語言支持的字典文件(英文)
- messages.zh_CN 多語言支持的字典文件(中文)
- app/ Play裏叫這個,至關於普通project裏的src目錄
- common/ 存放一些項目中用到的定義的常量或枚舉量
- Constants.java 通用常量
- ExamType.java 某個自定義類型的常量
- controllers/ MVC中的控制器層,以角色名開頭,命名區分;注:只負責request和resonpse,不負責具體業務邏輯
- AdmAccountController.java 管理員角色的Account模塊
- TeaExamController.java 教師角色的Exam模塊
- StuExamController.java 學生角色的Exam模塊
- managers/ 具體業務邏輯的包裝,供controller調用
- admin/ 供管理員角色的
- student/ 供學生角色的
- teacher/ 供教師角色的
- application/ 供系統通用的
- interfaces/ 供對外API的
- models/ 與數據庫對應的Model,用來作ORM(Object Relational Mapping)
- dao/ 封裝對數據庫model的原子操做,其中每一個具體model的DAO類都繼承GenericDao
- GenericDao.java 泛型DAO,提供通用的增刪改查操做
- ExamDao.java 具體的跟Exam相關的DAO
- data.structure/ 跟前臺交互約定的非數據庫model的數據類型
- Pagination.java 跟分頁相關的數據類型
- WrappedExam.java 對Exam結果的包裝,方便前臺交互
- utils/ helper方法
- application/ 跟應用相關的util
- DataUtil.java 跟應用和模塊相關的數據結構轉換方法
- ParamUtil.java 負責處理request的參數轉換方法
- ResponseUtil.java 負責對response結果的轉換方法
- SessionUtil.java 封裝對session的操做和轉換方法
- VcodeUtil.java 封裝對驗證碼的操做方法
- data/ 跟通用數據相關的util
- EncryptionUtil.java 加解密處理的轉換方法
- ExcelUtil.java 封裝對excel格式轉換的方法
- file/ 跟文件操做相關的util
- mail/ 跟收發郵件相關的util
- application/ 跟應用相關的util
- jobs/ 定時任務相關
- extensions/ 對頁面模板語法的擴展
- views/ 前臺頁面模板,見下面
- common/ 存放一些項目中用到的定義的常量或枚舉量
前臺部分jquery
- app/views/
- Base/ 頁面繼承的父頁面模板
- base_outer.html 不須要登陸的頁面父模板
- base_inner.html 須要登陸的頁面父模板
- base_admin.html 管理員角色的頁面父模板,繼承自base_inner.html
- base_teacher.html 教師角色的頁面父模板,繼承自base_inner.html
- Application/ 存放不須要登陸的頁面
- class、exam、exercise 等具體功能包的頁面
- tags/ 自定義頁面標籤的模板,至關於須要被include的頁面子塊
- examView.html 管理員和教師都須要用到此頁面塊,供複用
- passwordView.html 我的資料頁面和忘記密碼頁面都須要用到此頁面塊
- Base/ 頁面繼承的父頁面模板
- public/ 存放前端資源的目錄
- css/
- common/ 存放應用全部頁面通用的css
- bootstrap/ 主題庫相關
- jquery-ui/ 主題庫相關
- tablesorter/ 插件相關
- others/ 其餘小插件的css
- class、exam 等具體功能包的css
- file/ 存放頁面上供下載的靜態文件
- svg/ 存放編程題目源程序控制流圖的svg文件
- images/ 存放css的圖片
- bootstrap/ 主題庫相關的圖片
- jquery-ui/ 主題庫相關的圖片
- others/ 其餘小插件的圖片
- js/
- common/ 存放頁面通用的js,或者可複用的js
- bootstrap/ 主題庫的js
- jquery-ui/ 主題庫的js
- tablesorter/ 插件的js
- others/ 其餘小插件的js
- class、exam 等具體功能包的js
- css/
數據庫與ORM
本系統中使用 MySQL 數據庫,Play框架中使用JPA提供ORM(Object Relational Mapping)的功能。linux
一個簡單的Model類定義以下git
import javax.persistence.*;
import play.db.jpa.Model;
@Entity
@Table(name="exam")
public class Exam extends Model {
@Column(name="exam_name")
private String examName;
@ManyToOne
@JoinColumn(name="tea_id", referencedColumnName="id")
private Teacher teacher;
public String getExamName() {
return examName;
}
public void setExamName(String examName) {
this.examName = examName;
}
public Teacher getTeacher() {
return teacher;
}
public void setTeacher(Teacher teacher) {
this.teacher = teacher;
}
}
也是經過簡單的Annotation來配置數據庫字段和成員變量的對應關係,以及一對多/多對多的關係。注意,這裏不須要給Exam
添加額外的id
字段了,由於在Model
父類中已經由JPA自帶了id
字段,格式爲Long
,因此數據庫表裏定義id字段時要注意設置「自增」和int(32)
。web
DAO事務與泛型編程
如上面定義了Exam
類後,該Model就被注入了JPA提供的增刪改查操做了,爲了防止職責亂用,咱們統一約定由DAO層來封裝數據庫事務。這樣Exam
就會有個ExamDao
,Teacher
就會有個TeacherDao
,咱們會發現簡單的增刪改查對全部Model都適用的,爲了不簡單操做方法的重複,咱們引入「泛型Dao」的概念。面試
我在之前的文章中寫過關於JPA泛型DAO,須要定義一個泛型的GenericDao
類,提供通用的增刪改查操做。
public abstract class GenericDao<T, PK extends Serializable> {
private Class<T> clazz;
public GenericDao(){
// 反射獲取T.class,實參類型
clazz = (Class<T>)((ParameterizedType)getClass().getGenericSuperclass()).getActualTypeArguments()[0];
}
public T findById(PK id){
return (T) JPA.em().find(clazz, id);
}
public List<T> findByColumn(String columnName , Object value){
String[] columnNames = new String[1];
Object[] values = new Object[1];
columnNames[0] = columnName;
values[0] = value;
return findByColumns(columnNames , values);
}
public List<T> findByColumns(String[] columnNames , Object[] value){
String sqlPart = "";
for (int columnIdx = 0 ; columnIdx < columnNames.length ; columnIdx++){
sqlPart += "e." + columnNames[columnIdx] + " = '" + value[columnIdx].toString() + "'";
if (columnIdx < columnNames.length - 1){
sqlPart += " and ";
}
}
return (List<T>) JPA.em().createQuery("select e from " + clazz.getName() + " e where " + sqlPart).getResultList();
}
}
而具體Model都有具體的Dao去繼承它
public class ExamDao extends GenericDao<Exam, Long> {
public Exam findByTeaIdAndExamName(long teaId, String examName) {
String[] columns = {"teacher.id" , "examName"};
Object[] values = {teaId , examName};
List<Exam> list = this.findByColumns(columns, values);
if (list != null && list.size() > 0){
return list.get(0);
}
return null;
}
public List<Exam> findByTeaOpenid(String teaOpenid) {
return this.findByColumn("teacher.teaOpenid", teaOpenid);
}
}
關於GenericDao
的更多細節請看JPA泛型DAO
後端MVC框架
從上面的項目結構中已經看到,後端調用層次結構爲 Controller
->Manager
->Dao
->Model
,Controller
最終拿到Model
數據傳給前端頁面,可見這是僞MVC。更準確來講是「分層」結構:上層能夠調用下層,下層不能調用上層;同時上層也不能跨層調用。
咱們這裏說框架的MVC,更着重於Controller
怎麼和頁面View
掛鉤起來,並不太涉及Model
的事,這裏就須要路由(url)配置。
route配置規範
# 非登陸的頁面
GET / Application.index
GET /faq/{category}/{sub} Application.{category}{sub}FAQ
# 登陸和註冊
POST /login LoginController.login
# 角色的功能模塊
* /tea/{action} TeacherController.{action}
* /tea/exam/{action} TeaExamController.{action}
# Map static resources from the /app/public folder to the /public path
GET /public/ staticDir:public
路由配置支持定義請求方式GET
or POST
,也可使用通配符,注意對於「更改」操做必定要使用POST
,這是http的規範。url和Controller
中的方法一一對應,而且支持變量替代,減小類似的配置條目。
前端頁面繼承與複用
對於前端頁面模板,Play框架裏一樣支持頁面繼承,Play中使用Groovy模板引擎。關於頁面繼承細節可看這篇文章前端要給力 — 平凡之路,雖然裏面是以Django框架的模板引擎爲例,可是原理相同,模板語法略有不一樣而已。
前端UI組件的沉澱
在mooctest這個項目中,前端整體上用頁面繼承和自定義頁面tags來組織。雖然項目起步時偷懶沒有引入RequireJS來組織js,但最終仍是拎出了很多js組件,使用最樸素的js類定義和jquery插件的寫法來封裝代碼。
一、動態圖表
使用Highcharts做圖表庫,因爲項目中大部分圖表都是動態從後端取數據的,因此在Highcharts上面封裝了一層ajax過程,而且將各圖表配置options作了剝離。具體細節可見下面這篇文章:
二、分頁插件
這是一個jquery插件,可自動生成帶「滑動窗口」的分頁數目,可支持分頁直接刷新頁面,或者可自行配置ajax分頁替換函數。具體細節可見下面這篇文章:
三、學校選擇器
這是mooctest項目中最複雜的一個前端功能,而且有多處地方須要編輯學校,須要提供搜索和自定義添加學校的功能。具體實現細節可見下面這個系列文章:
這是系列長文,講述瞭如何把一段生硬實現的代碼一步一步封裝和擴展成爲一個可配置的UI組件!
多語言的支持
前端部分講完了,咱們最後再看個和前端略有掛鉤的需求,就是多語言支持。要在首頁提供中文和英文的選項,而且默認使用系統語言。
系統語言判斷
public class Application extends Controller {
static final String DEFAULT_LANGUAGE = "zh_CN";
@Before
static void setGlobalLang(){
// langAction會將lang存入session
String lang = SessionUtil.getLang(session);
if(lang == null){
// 獲取瀏覽器系統語言
List<String> langs = request.acceptLanguage();
for(String temp : langs){
// 瀏覽器發送的爲 zh-CN
if(temp.contains("zh")){
temp = DEFAULT_LANGUAGE;
}
if(Play.langs.contains(temp)){
lang = temp;
break;
}
}
if(lang == null){
lang = DEFAULT_LANGUAGE;
}
// 更新到session
SessionUtil.putLang(session, lang);
}
Lang.set(lang);
}
}
這裏使用Play框架裏的攔截器的概念,即上面Annotation的@Before
,使得每一個頁面的action都會先執行setGlobalLang
。語言的判斷順序爲:先取session裏存的語言,再取瀏覽器request頭裏傳來的系統支持語言,都取不到時再提供個默認語言。
此外,還需爲首頁的中英文切換再提供個額外的action
public class Application extends Controller {
/** 多語言 */
public static void langAction(){
String lang = params.get("lang");
setLang(lang);
index("");
}
static void setLang(String lang){
if(lang == null || !Play.langs.contains(lang)){
lang = DEFAULT_LANGUAGE;
}
// 更新到session
SessionUtil.putLang(session, lang);
Lang.set(lang);
}
}
語言字典
在本文最初提到的項目目錄結構中就有關於messages的文件,messages.zh_CN
和messages.en
就是中英文的字典文件。
Play框架在這一點方面作的比較簡陋,好像一個語言只能有一個字典文件,所以咱們須要使用「命名空間」的概念進行分組管理。
#key格式:頁面名.[groupName].xxx
#通用頁面 common.[groupName].xxx
#首頁
################################
index.links.guide = GUIDE
index.links.download = DOWNLOADS
這裏配置了英文文案,一樣也要在messages.zh_CN
文件裏配置相同key的中文文案。
後端返回文案
若是要在後端的Controller
裏向前端返回錯誤文案,多語言的支持得使用 play.i18n.Messages
// import play.i18n.Messages;
// 這裏的文案key與上面的語言字典中保持一致
Messages.get("LoginController.accountNotExist")
前端的文案
在前端的頁面模板中,直接使用 &{'common.browserTitles.mooctest'}
就可以使用語言字典中的key
可是Play框架只會對模板文件作處理,對其注入通用變量和後端數據,模板文件實際上是由後端負責渲染(轉成標準html)的。由後端處理的頁面模板中能夠任意使用 &{'your_text_key'}
語言標記,可是這在.js
文件中是不被支持的。
咱們須要在全部頁面的base父頁面中定義一個內聯script,事先定義好全部.js
中須要使用到的文案。
<!-- 全局多語言文案,供通用js使用 -->
<script type="text/javascript">
window.LANG_TEXT = {
OK: "&{'common.btn.ok'}",
CANCEL: "&{'common.btn.cancel'}",
DONE: "&{'common.btn.done'}"
};
</script>
內聯script是在模板文件中的,能夠被Play框架處理,因而語言文案就被存在了全局window
裏。在具體功能的.js
文件中能夠直接使用window.LANG_TEXT
變量。
郵件隊列與定時任務
最後我再來講一個後端額外的小功能,發送郵件,由第三方EDM(Email Direct Marketing)商提供服務。
EDM服務購買
能夠在網上找到不少這樣的EDM服務商,有的是專門作企業短信和郵件營銷的,也有的是域名主機和服務都作的。我這兒就不打廣告了,自行找一家有點規模的穩定一點的EDM服務商便可。
域名配置
買好EDM帳號後,在EDM管理平臺上就可發郵件了,可是它們默認會給你分配一個帶 edm04621@service.xxx.com
相似這樣的郵箱。這種郵箱發出來的郵件十有八九會被扔進垃圾箱或者被攔截掉,所以咱們要配置本身域名的郵箱。
設置一個域名的mx、txt和cname記錄,以example.com域爲例:
edm.example.com CNAME edm.edmcn.cn
edm.example.com MX sender.f.wsztest.com
edm.example.com TXT v=spf1 include:spf.ezcdn.cn ~all
域名解析成功後,就可在EDM管理平臺使用本身域名驗證過的郵箱地址了,好比叫service@edm.mooctest.net
,就能夠大大減小郵件被扔進垃圾箱的機率。
SMTP接口
上面的配置都完成後,確保在EDM管理平臺上能夠成功發郵件後,就能夠去申請開通EDM-SMTP服務。在程序中能夠經過javax.mail
庫去創建郵件Transport協議。
import java.io.UnsupportedEncodingException;
import java.util.List;
import java.util.Properties;
import javax.mail.MessagingException;
import javax.mail.Session;
import javax.mail.Transport;
import javax.mail.internet.AddressException;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimeMessage.RecipientType;
import common.Constants;
public class SimpleMailSender {
private static final String SMTP_EDM = "smtp.trigger.edmcn.cn";
private final transient Properties props = System.getProperties();
private transient MailAuthenticator authenticator;
private transient Session session;
public SimpleMailSender(final String smtpHostName, final String username,
final String password) {
init(username, password, smtpHostName);
}
public SimpleMailSender(final String username, final String password) {
String smtpHost;
// EDM賬號
if(isEDM(username)){
smtpHost = SMTP_EDM;
}
else{
smtpHost = "smtp." + username.split("@")[1];
}
init(username, password, smtpHost);
}
private void init(String username, String password, String smtpHostName) {
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.host", smtpHostName);
authenticator = new MailAuthenticator(username, password);
session = Session.getInstance(props, authenticator);
}
private boolean isEDM(String account){
if(account.startsWith("edmc") && !account.contains("@")){
return true;
}
return false;
}
private InternetAddress getSenderAddress() throws AddressException, UnsupportedEncodingException{
if(isEDM(authenticator.getUsername())){
return new InternetAddress(Constants.EDM_SENDER_ADDRESS, Constants.EDM_SENDER_NAME);
}
return new InternetAddress(authenticator.getUsername(), Constants.DEFAULT_SENDER_NAME);
}
public void send(List<String> recipients, String subject, Object content)
throws AddressException, MessagingException, UnsupportedEncodingException {
final MimeMessage message = new MimeMessage(session);
message.setFrom(getSenderAddress());
final int num = recipients.size();
InternetAddress[] addresses = new InternetAddress[num];
for (int i = 0; i < num; i++) {
addresses[i] = new InternetAddress(recipients.get(i));
}
message.setRecipients(RecipientType.TO, addresses);
message.setSubject(subject);
message.setContent(content.toString(), "text/html;charset=utf-8");
Transport.send(message);
}
}
隊列設計
使用過EDM發送郵件的人會知道,就算咱們配置了本身域名的郵箱地址,在使用SMTP協議發送時,也會遇到頻率過快,或者對方郵箱拒收,等失敗狀況。所以咱們要設計一套容錯和重試的機制。
import javax.persistence.*;
import play.db.jpa.Model;
@Entity
@Table(name="email_task")
public class EmailTask extends Model {
@Column(name="receiver")
private String receiver;
@Column(name="subject")
private String subject;
@Column(name="content")
private String content;
@Column(name="try_times")
private Integer tryTimes;
public EmailTask(){
// default
this.tryTimes = 0;
}
// 省略getter和setter
}
如本文上面提到的數據庫與ORM所述,這裏設計一個EmailTask
的Model,記錄下收件人、主題和正文內容,再額外存個tryTimes
字段。這裏咱們能夠規定,當重試3次仍失敗後,就忽略該郵件任務。
當發送當即郵件時,好比「忘記密碼」的郵件,直接使用上面的SimpleMailSender
發送郵件,若是失敗,則將郵件信息存成EmailTask
存到數據庫。而當發送非當即的郵件時,好比通知類的郵件,只需將郵件內容生成EmailTask
對象存到數據庫,供定時任務來調度。
當即任務與定時任務
上面的郵件隊列設計中所說的「當即郵件」和「非當即郵件」,其實就是「當即任務」和「定時任務」。在Play框架中有Jobs來實現任務調度。
import play.jobs.Job;
public class InstantMailJob extends Job {
private static EmailTaskDao taskDao = new EmailTaskDao();
private String receiver;
private String subject;
private String content;
public InstantMailJob(String receiver, String subject, String content){
this.receiver = receiver;
this.subject = subject;
this.content = content;
}
public void doJob(){
try {
MailJobUtil.sendMail(receiver, subject, content);
} catch (Exception e) {
e.printStackTrace();
System.out.println("Send mail error for receiver " + receiver);
// 發送失敗,加入task,待下次再發
EmailTask task = new EmailTask();
task.setReceiver(receiver);
task.setSubject(subject);
task.setContent(content);
// 已失敗1次
task.setTryTimes(1);
taskDao.save(task);
}
}
}
這就是「當即郵件」任務的Job,得 override doJob
方法,郵件發送失敗的話就加入EmailTask
。使用時以下調用便可
new InstantMailJob(receiver, subject, content).now();
而對於「非當即郵件」任務,要使用Play框架的定時任務Job,而且設置間隔時間。
import play.jobs.Every;
import play.jobs.Job;
@Every("1mn")
public class BackgroundMailJob extends Job {
private static EmailTaskDao taskDao = new EmailTaskDao();
public void doJob(){
// 避免郵件服務器異常,一次只發前10個
List<EmailTask> tasks = taskDao.getTopTasks();
for(EmailTask task : tasks){
try {
MailJobUtil.sendMail(task.getReceiver(), task.getSubject(), task.getContent());
} catch (Exception e) {
e.printStackTrace();
System.out.println("Send mail error for receiver " + task.getReceiver());
// 把當前任務加到隊尾
EmailTask failedTask = new EmailTask();
failedTask.setReceiver(task.getReceiver());
failedTask.setSubject(task.getSubject());
failedTask.setContent(task.getContent());
// 累計失敗次數
failedTask.setTryTimes(task.getTryTimes() + 1);
taskDao.save(failedTask);
}
// 刪除成功的任務
taskDao.remove(task);
}
}
}
一樣也要 override doJob
方法,但這裏還得設置任務週期 @Every("1mn")
,這個有點相似linux中的crontab。我這裏設置了每1分鐘執行一次任務,爲了不郵件SMTP調用頻率太快而失敗,每次執行Job時只取隊列中前幾個EmailTask
。
郵件統計數據
這是一開始在EDM管理平臺上批量發送郵件的統計數據,發現軟退率不低,查看郵局統計後發現是QQ郵箱廣泛網關攔截。
而下面是使用了EDM-SMTP協議和郵件隊列發送的結果統計,可見成功率稍微高一點。倒數第二條記錄軟退很高,是由於幾乎都是QQ郵箱!
後記
項目能堅持作下去不容易,寫文章更不容易,對本身是個總結,也但願能夠幫到更多的人。