iTranswarp 是廖雪峰大神官方網(wǎng)站的開源 CMS向挖,用來托管個人的網(wǎng)站月帝,簡潔夠用先馆。
1 技術(shù)架構(gòu)
iTranswarp 主體上是使用了 Spring Boot 2.2.6 的一個單體應(yīng)用泣懊,其頁面模板引擎為 pebbletemplates,并且使用了 redis 緩存和全文檢索 lucene麻惶,數(shù)據(jù)存儲使用其自定義的簡化數(shù)據(jù)持久框架 warpdb馍刮,其底層注入 JdbcTemplate 完成數(shù)據(jù)持久化。
數(shù)據(jù)庫是常用的 MySQL窃蹋,使用了 HikariCP 數(shù)據(jù)源連接池卡啰。
Markdown 解析器使用的是 commonmark-java:一個基于 CommonMark 規(guī)范解析和渲染 Markdown 文本的 Java 庫静稻,特點(diǎn)是小、快匈辱、靈活振湾。后續(xù)需要通過這一塊擴(kuò)展 gitbook 內(nèi)容直接導(dǎo)入(wiki)的功能。
使用了 JDK 11亡脸。
2 程序分析
2.1 數(shù)據(jù)持久化
iTranswarp 的數(shù)據(jù)持久化是通過其自定義的簡化數(shù)據(jù)持久框架 warpdb 來完成的押搪。
在 WarpDb 類里面使用了 Spring 的 JdbcTemplate 來完成最終的數(shù)據(jù)持久化操作。
public class WarpDb {
final Log log = LogFactory.getLog(getClass());
JdbcTemplate jdbcTemplate;
warpdb 持久化框架最重要的一個類是范型化的 Mapper<T>
類:
final class Mapper<T> {
final Class<T> entityClass;
final String tableName;
// @Id property:
final AccessibleProperty[] ids;
// @Version property:
final AccessibleProperty version;
// all properties including @Id, key is property name (NOT column name)
final List<AccessibleProperty> allProperties;
// lower-case property name -> AccessibleProperty
final Map<String, AccessibleProperty> allPropertiesMap;
final List<AccessibleProperty> insertableProperties;
final List<AccessibleProperty> updatableProperties;
// lower-case property name -> AccessibleProperty
final Map<String, AccessibleProperty> updatablePropertiesMap;
final BeanRowMapper<T> rowMapper;
final String selectSQL;
final String insertSQL;
final String insertIgnoreSQL;
final String updateSQL;
final String deleteSQL;
final String whereIdsEquals;
final Listener prePersist;
final Listener preUpdate;
final Listener preRemove;
final Listener postLoad;
final Listener postPersist;
final Listener postUpdate;
final Listener postRemove;
...
可以通過 ArticleService 文章服務(wù)類的 createArticle 方法看到清晰的數(shù)據(jù)操作過程浅碾。
@Transactional
public Article createArticle(User user, ArticleBean bean) {
bean.validate(true);
getCategoryById(bean.categoryId);
Article article = new Article();
article.id = IdUtil.nextId();
article.userId = user.id;
article.categoryId = bean.categoryId;
article.name = bean.name;
article.description = bean.description;
article.publishAt = bean.publishAt;
article.tags = bean.tags;
AttachmentBean atta = new AttachmentBean();
atta.name = article.name;
atta.data = bean.image;
article.imageId = attachmentService.createAttachment(user, atta).id;
article.textId = textService.createText(bean.content).id;
this.db.insert(article);
return article;
}
- 創(chuàng)建實(shí)體 Article(使用 JPA 注解 @Entity大州、@Table、@Column 等進(jìn)行標(biāo)注)垂谢,并設(shè)置各種屬性厦画;
- 調(diào)用 WarpDb 的 insert 方法,將實(shí)體 Article 存入數(shù)據(jù)庫滥朱;
持久化框架通過傳入的實(shí)體對象獲取其類其 Mapper<Article>
根暑,構(gòu)建對應(yīng)的 sql,最終通過 jdbcTemplate 執(zhí)行這段sql徙邻,將其存儲到數(shù)據(jù)庫中购裙。
private <T> boolean doInsert(boolean ignore, T bean) {
try {
int rows;
final Mapper<?> mapper = getMapper(bean.getClass());
final String sql = ignore ? mapper.insertIgnoreSQL : mapper.insertSQL;
...
rows = jdbcTemplate.update(sql, args);
...
}
2.2 視圖模板
iTranswarp 的視圖模板使用的是不太常見、但是效率較高的 Pebble Templates:簡單高效鹃栽,容易上手躏率。
Pebble 官方提供了 Spring Boot 的 starter 集成,但是 iTranswarp 使用了原始的集成方式:在 MvcConfiguration 類中注冊了 ViewResolver 為 Spring MVC 提供視圖解析器民鼓。
@Bean
public ViewResolver pebbleViewResolver(@Autowired Extension extension) {
// disable cache for native profile:
boolean cache = !"native".equals(activeProfile);
logger.info("set cache as {} for active profile is {}.", cache, activeProfile);
PebbleEngine engine = new PebbleEngine.Builder().autoEscaping(true).cacheActive(cache).extension(extension)
.loader(new ClasspathLoader()).build();
PebbleViewResolver viewResolver = new PebbleViewResolver();
viewResolver.setPrefix("templates/");
viewResolver.setSuffix("");
viewResolver.setPebbleEngine(engine);
return viewResolver;
}
- 視圖解析前綴為 templates/薇芝;
- 視圖解析后綴為空;
以 ManageController 控制器為例丰嘉,看看其中的“新建文章” 服務(wù)的代碼:
@GetMapping("/article/article_create")
public ModelAndView articleCreate() {
return prepareModelAndView("manage/article/article_form.html", Map.of("id", 0, "action", "/api/articles"));
}
- 使用 "templates/manage/article/article_form.html" 這個模板夯到。
模板文件 _base.html 是最基礎(chǔ)的頁面,可以在其上添加你需要的內(nèi)容饮亏,例如網(wǎng)站備案信息耍贾。
為了簡便起見(畢竟我只用一次),硬編碼添加路幸,沒有擴(kuò)展為“管理控制臺”里面的設(shè)置屬性荐开。
<div id="footer">
<div class="x-footer uk-container x-container">
<hr>
<div class="uk-grid">
<div class="x-footer-copyright uk-width-small-1-2 uk-width-medium-1-3">
<p>
<a href="/" title="version={{ __version__ }}">{{ __website__.name }}</a> {{ __website__.copyright|raw }}
<a target="blank">蜀ICP備20013663號</a>
<br>
Powered by <a target="_blank">iTranswarp</a>
</p>
</div>
...
2.3 數(shù)據(jù)庫表
數(shù)據(jù)庫表命名清晰,自說明強(qiáng)简肴。
系統(tǒng)配套數(shù)據(jù)庫表19張晃听,其作用分別如下:
序號 | 表名 | 用途 | 說明 |
---|---|---|---|
1 | users | 用戶表 | 需要線下添加用戶,線上不需要注冊用戶 |
2 | local_auths | 本地用戶認(rèn)證信息 | 存放users中用戶的密碼 |
3 | oauths | 第三方認(rèn)證 | |
4 | ad_slots | 廣告位 | 在管理控制臺中的“廣告-廣告位”功能中設(shè)置 |
5 | ad_periods | 廣告期限 | 在管理控制臺中的“廣告-廣告期限”功能中設(shè)置 |
6 | ad_materials | 廣告素材 | 需要在有廣告位和廣告期限的前提下,使用sponsor用戶登錄能扒,在管理控制臺中的“廣告-廣告素材”功能中設(shè)置 |
7 | settings | 設(shè)置表 | 在管理控制臺中的“設(shè)置”功能中配置 |
8 | wikis | 維基 | 創(chuàng)建書籍佣渴、教程用,在管理控制臺中的“維基”功能中維護(hù) |
9 | wiki_pages | 維基頁面 | 存放維基頁面初斑,使用Markdown編輯辛润,內(nèi)容通過textId存入texts表中,需要擴(kuò)展成通過gitbook批量導(dǎo)入一本書 |
10 | navigations | 導(dǎo)航 | 在管理控制臺中的“導(dǎo)航”功能中配置见秤,在首頁的頂部導(dǎo)航欄顯示频蛔。導(dǎo)航有5種類型:/category/xxx,文章類型(需要先創(chuàng)建文章類型秦叛,并在數(shù)據(jù)庫中查詢id進(jìn)行配置導(dǎo)航)晦溪;/single/xxx,頁面(需要在管理控制臺“頁面”創(chuàng)建挣跋,并在數(shù)據(jù)庫中查詢id進(jìn)行配置導(dǎo)航)三圆;/wiki/xxx,維基避咆,就是教程了舟肉,也是先創(chuàng)建在配置導(dǎo)航;/discuss查库,論壇(系統(tǒng)內(nèi)置)路媚;外部鏈接 |
11 | boards | 論壇 | 在管理控制臺中的“討論”功能中維護(hù) |
12 | articles | 文章 | 在管理控制臺中的“文章”功能中維護(hù) |
13 | topics | 話題 | 文章中第一個評論,評論的答復(fù)在replies中樊销。也是論壇的話題整慎。文章評論和論壇話題混到一起,有點(diǎn)兒不清晰 |
14 | replies | 回復(fù) | 文章中評論的回復(fù)围苫,話題的回復(fù) |
15 | attachments | 附件 | 例如文章中的圖片裤园,通過imageId指定到附件中的記錄,附件記錄中的resourceId剂府,在resources表中以base64編碼存儲圖片 |
16 | resources | 資源 | 資源存儲表拧揽,比如存儲文章、wiki中的圖片腺占,字段content需要將類型從mediumtext修改成LongText淤袜,支持高清的圖片 |
17 | single_pages | 頁面 | 在管理控制臺中的“頁面”功能中維護(hù) |
18 | categories | 文章類型 | 是文章的分類,比如設(shè)置“程序人生”文章類別 |
19 | texts | 文本 | 存放文本衰伯,如文章(articles)通過textId將Markdown文本存儲在這張表的一條記錄中铡羡。每做一次修改保存就會在這里添加一條記錄。content字段類型text需要修改為mediumtext以容納更多的文字 |
這樣 iTranswarp 就將所有的內(nèi)容都存放到 MySQL 數(shù)據(jù)庫中了嚎研,而不需要使用服務(wù)器文件系統(tǒng)蓖墅,備份 CMS 網(wǎng)站就變成了備份數(shù)據(jù)庫。在小型個人網(wǎng)站應(yīng)用場景中临扮,數(shù)據(jù)量不會特別大论矾,這樣的設(shè)計(jì)確實(shí)非常方便了莺债。
2.4 系統(tǒng)角色
系統(tǒng)內(nèi)有5種角色众旗,由 Role 類來定義:
package com.itranswarp.enums;
public enum Role {
ADMIN(0),
EDITOR(10),
CONTRIBUTOR(100),
SPONSOR(1_000),
SUBSCRIBER(10_000);
public final int value;
Role(int value) {
this.value = value;
}
}
3 使用說明
右上角地圖圖標(biāo)是國際設(shè)定,支持英語和中文兩種語言椒楣。
系統(tǒng)的設(shè)置蚜退、內(nèi)容創(chuàng)作闰靴,都需要在系統(tǒng)內(nèi)登錄。
查看數(shù)據(jù)庫 users 表钻注,登錄用戶有5個蚂且,默認(rèn)密碼為 password:
用戶名 | 用戶郵箱 | 角色 | 密碼 |
---|---|---|---|
admin | admin@itranswarp.com | ADMIN | password |
editor | editor@itranswarp.com | EDITOR | password |
contributor | contributor@itranswarp.com | CONTRIBUTOR | password |
sponsor | sponsor@itranswarp.com | SPONSOR | password |
subscriber | subscriber@itranswarp.com | SUBSCRIBER | password |
系統(tǒng)的維護(hù)操作,都可以使用 admin 用戶登錄幅恋,使用管理控制臺進(jìn)行維護(hù)杏死、內(nèi)容創(chuàng)作。
管理控制臺管理的內(nèi)容捆交,請參考“數(shù)據(jù)庫表”小節(jié)中的“說明”列淑翼。
4 系統(tǒng)擴(kuò)展
為了更好地維護(hù)網(wǎng)站內(nèi)容,需要提供在線 api:
- 支持導(dǎo)入服務(wù)器本地指定目錄下的 Markdown 文章(方便離線寫作或其他網(wǎng)站備份下來的文章品追,并要將待導(dǎo)入文章中的圖片下載解析并存入本 CMS 系統(tǒng)的數(shù)據(jù)庫表中)玄括;
- 支持導(dǎo)入gitbook到 wiki 中(圖片支持本地圖片和網(wǎng)絡(luò)圖片的導(dǎo)入);
- 支持導(dǎo)出 wiki 到 gitbook 格式到服務(wù)器的某個目錄并 zip 下載肉瓦;
- 支持導(dǎo)出某一篇文章或某一些文章到服務(wù)器的某個目錄并 zip 下載遭京。
4.1 擴(kuò)展工具類
對 iTranswarp 的工具類進(jìn)行擴(kuò)展或新增,以統(tǒng)一提供額外的功能泞莉。
4.1.1 圖像處理類 ImageUtil
主要添加讀取圖像到字符數(shù)組的方法洁墙,供文章或wiki中的附件(圖片)導(dǎo)入調(diào)用。
4.1.1.1 readWebImageStream
對 ImageUtil 類進(jìn)行擴(kuò)展戒财,添加 readWebImageStream 方法热监,使用apache HttpClients 組件,從網(wǎng)絡(luò)上讀取圖片文件饮寞。
/**
* 從網(wǎng)絡(luò)鏈接獲取圖片
* @param imgUrl 網(wǎng)絡(luò)圖片地址?
* @param imgType 圖片類型:jpeg|bmp|gif|png
* @return 存放圖片的字節(jié)數(shù)組
* @throws Exception
*/
public static byte[] readWebImageStream(String imgUrl, String imgType) throws Exception {
byte[] bitImg;
CloseableHttpClient httpClient = HttpClients.createDefault();
CloseableHttpResponse response = null;
HttpGet httpGet = new HttpGet(imgUrl);
// 瀏覽器表示
httpGet.addHeader("User-Agent", "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.7.6)");
// 傳輸?shù)念愋? httpGet.addHeader("Content-Type", "image/" + imgType.toLowerCase());//有效類型為:image/jpeg image/bmp image/gif image/png
try {
// 執(zhí)行請求
response = httpClient.execute(httpGet);
// 獲得響應(yīng)的實(shí)體對象
HttpEntity entity = response.getEntity();
// 包裝成高效流
BufferedInputStream bis = new BufferedInputStream(entity.getContent());
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] byt = new byte[1024 * 8];
Integer len = -1;
while ((len = bis.read(byt)) != -1) {
bos.write(byt, 0, len);
}
bitImg = bos.toByteArray();
bos.close();
bis.close();
} finally {
// 釋放連接
if (null != response) {
try {
response.close();
httpClient.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return bitImg;
}
4.1.1.2 readLocalImageStream
從本地文件路徑下讀取文件孝扛,返回到字節(jié)數(shù)組中。
/**
* 從本地文件中讀取到字節(jié)數(shù)組
* @param fileName 文件路徑
* @return
* @throws Exception
*/
public static byte[] readLocalImageStream(String fileName) throws Exception {
byte[] bitImg;
try (InputStream in = new FileInputStream(fileName)) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[1024 * 8];
int n = 0;
while ((n = in.read(buffer)) != -1) {
out.write(buffer, 0, n);
}
bitImg = out.toByteArray();
}
return bitImg;
}
4.1.2 Markdown 文件處理類 MdFileUtil
這個類主要處理 Markdown 文件的操作幽崩。
4.1.2.1 readLines(File file)
將文本文件(一般就是一個 Markdown 文件)按行讀取文件到 List 中苦始,后續(xù)需要提取其中的圖片行,以供從網(wǎng)絡(luò)上下載圖片文件或讀取本地圖片文件慌申,并以附件的形式導(dǎo)入到 iTranswarp 數(shù)據(jù)庫中陌选。
/**
* 按行讀取文件到List中
*
* @param file 待讀取的文件
* @return 按行存儲的list
* @throws FileNotFoundException
* @throws IOException
*/
public static List<String> readLines(File file) throws FileNotFoundException, IOException {
try (BufferedReader bf = new BufferedReader(new FileReader(file))) {
List<String> lines = new ArrayList<String>();
String line;
// 按行讀取字符串
while ((line = bf.readLine()) != null) {
lines.add(line);
}
return lines;
}
}
4.1.2.2 readLines(String content)
從文本 content(一般就是從網(wǎng)頁維護(hù)傳送到后臺的一篇 Markdown 文本內(nèi)容)讀取內(nèi)容到一個 List 中理郑,后續(xù)需要提取其中的圖片行,以供從網(wǎng)絡(luò)上下載圖片文件或讀取本地圖片文件咨油,并以附件的形式導(dǎo)入到 iTranswarp 數(shù)據(jù)庫中您炉。
/**
* 從文本中按行劃分為List
*
* @param content
* @return
*/
public static List<String> readLines(String content) {
String[] strs = content.split(System.getProperty("line.separator"));
List<String> lines = Arrays.asList(strs);
return lines;
}
4.1.2.3 readImageLines
從給定的 List(是按行存放的 Markdown 文本內(nèi)容)中讀取 image 標(biāo)簽的行到返回的 List 中,后續(xù)將從這個返回的圖片 List 中獲取 image 標(biāo)簽信息供從網(wǎng)絡(luò)上下載圖片文件或讀取本地圖片文件役电,并以附件的形式導(dǎo)入到 iTranswarp 數(shù)據(jù)庫中赚爵。
/**
* 獲取圖片標(biāo)記行
*
* @param lines MD文件的所有行
* @return MD文件中的圖片標(biāo)記行
*/
public static Map<Integer, MdImageMarkBean> readImageLines(List<String> lines) {
Map<Integer, MdImageMarkBean> imgs = new HashMap<Integer, MdImageMarkBean>();
for (int i = 0; i < lines.size(); i++) {
String line = trimLeft(lines.get(i));
if (line.startsWith("![") && line.endsWith(")")) {// MarkDown 圖片標(biāo)簽為 ![這是一張圖片](http://www.abc.com/def.png)
String imgUrl = line.substring(line.indexOf("](") + 2, line.lastIndexOf(")"));// 提取圖片url地址
String imgTip = line.substring(line.indexOf("![") + 2, line.indexOf("]"));// 提取圖片的描述信息
String imgType = "jpeg";// 提取圖片的類型
if (line.indexOf(".png") != -1) {
imgType = "png";
} else if (line.indexOf(".jpg") != -1) {
imgType = "jpeg";
} else if (line.indexOf(".gif") != -1) {
imgType = "gif";
} else if (line.indexOf(".bpm") != -1) {
imgType = "bmp";
}
// 判斷圖片是否是web圖片
String location = "local"; // 默認(rèn)本地圖片
if (line.indexOf("http://") != -1 || line.indexOf("https://") != -1) {
location = "web";
}
String imageMark = "![" + imgTip + "](" + "images/" + imgTip + "." + imgType + ")";// 保存到當(dāng)前文件的imges目錄下
MdImageMarkBean imgBean = new MdImageMarkBean();// 創(chuàng)建這張圖片的Map存儲對象
imgBean.setLine(i);
imgBean.setUrl(imgUrl);
imgBean.setTip(imgTip);
imgBean.setType(imgType);
imgBean.setLocation(location);// 圖片位置:本地local|網(wǎng)絡(luò)web
imgBean.setImageMark(imageMark);// 更新過的圖片標(biāo)簽
imgs.put(i, imgBean);
}
}
return imgs;
}
4.1.2.4 writeFile(List<String> lines, File file)
將按行存儲的 Markdown 內(nèi)容(lines參數(shù),這個時候法瑟,image 標(biāo)簽已經(jīng)做了對應(yīng)的替換冀膝,如從網(wǎng)絡(luò)圖片更換為本地文件)保存到文件中。
/**
* 將按行存儲的list寫入文件
*
* @param lines
* @param file
* @throws IOException
*/
public static void writeFile(List<String> lines, File file) throws IOException {
try (FileWriter fw = new FileWriter(file)) {
for (int i = 0; i < lines.size(); i++) {
fw.write(lines.get(i));
fw.write(System.getProperty("line.separator"));
}
}
}
4.1.2.5 writeFile(byte[] bytes, File file)
將字節(jié)數(shù)組(一般是從網(wǎng)絡(luò)流中讀取的網(wǎng)絡(luò)圖片)存儲到本地文件霎挟,例如將簡書文章中的網(wǎng)絡(luò)圖片下載保存到本地文件夾中窝剖。
/**
* 將字節(jié)數(shù)組寫入文件
*
* @param bytes
* @param file
* @throws IOException
*/
public static void writeFile(byte[] bytes, File file) throws IOException {
try (FileOutputStream fos = new FileOutputStream(file);
BufferedOutputStream bos = new BufferedOutputStream(fos)) {
bos.write(bytes);
}
}
4.2 擴(kuò)展附件服務(wù) AttachmentService 類
擴(kuò)展 AttachmentService 類,添加2個方法向系統(tǒng)添加附件(也就導(dǎo)入MD文件中的圖片)酥夭。
4.2.1 importWebAttachment
通過 url 導(dǎo)入 web 上的圖片到附件中枯芬。
/**
* 通過url導(dǎo)入網(wǎng)絡(luò)圖片創(chuàng)建附件
* @param user 當(dāng)前登錄用戶
* @param url 圖片的網(wǎng)絡(luò)地址
* @param type 圖片類型
* @param name 存入系統(tǒng)附件的名稱
* @return
* @throws Exception
*/
@Transactional
public Attachment importWebAttachment(User user, String url, String type, String name) throws Exception {
AttachmentBean bean = new AttachmentBean();
byte[] img = ImageUtil.readWebImageStream(url, type);
bean.data = Base64.getEncoder().encodeToString(img);
bean.name = name;
return createAttachment(user, bean);
}
4.2.2 importLocalAttachment
通過文件路徑,導(dǎo)入服務(wù)器本地指定文件路徑下的圖片到附件中采郎。
/**
* 導(dǎo)入本地文件(圖片)創(chuàng)建附件
* @param user 當(dāng)前登錄用戶
* @param url 服務(wù)器上文件絕對路徑
* @param type 圖片類型
* @param name 存入系統(tǒng)附件的名稱
* @return
* @throws Exception
*/
@Transactional
public Attachment importLocalAttachment(User user, String url, String name) throws Exception {
AttachmentBean bean = new AttachmentBean();
byte[] img = ImageUtil.readLocalImageStream(url);
bean.data = Base64.getEncoder().encodeToString(img);
bean.name = name;
return createAttachment(user, bean);
}
4.3 導(dǎo)入文章
導(dǎo)入文章會根據(jù)文章的位置千所,分為本地文章和網(wǎng)絡(luò)文章:
- 本地文章:Markdown文件及其以來的本地圖片文件事先已經(jīng)寫好并上傳到服務(wù)器的指定目錄中;
- 網(wǎng)絡(luò)文章:在網(wǎng)絡(luò)上(CSDN或簡書等)寫好的文章蒜埋,特點(diǎn)是圖片都是上傳在網(wǎng)絡(luò)服務(wù)器上淫痰;
4.3.1 擴(kuò)展文章服務(wù) ArticleService 類
為其添加導(dǎo)入文章的服務(wù)方法 importArticle,在方法內(nèi)部區(qū)分網(wǎng)絡(luò)文章或本地文章的導(dǎo)入整份。
/**
* 導(dǎo)入文章
*
* @param user 當(dāng)前登錄用戶
* @param bean 從頁面?zhèn)鬟f過來的文章值對象待错,其content做了區(qū)分復(fù)用(將就用了,不改了)
* @param source 源=local(本地導(dǎo)入)|web(網(wǎng)絡(luò)導(dǎo)入)
* @return
* @throws Exception
*/
@Transactional
public Article importArticle(User user, ArticleBean bean, String source) throws Exception {
Article article = new Article();
List<String> lines;
String fileDir = "";
if ("local".equals(source.trim().toLowerCase())) {// 將Markdown文件及其圖片文件都上傳到服務(wù)器了
File file = new File(bean.content);// bean.content 是借用來存儲服務(wù)器上Markdown文件的絕對位置的烈评,例如:/Users/kevin/temp/test.md
fileDir = file.getParent();// 服務(wù)器上存放Markdown文件的文件夾火俄,供圖片標(biāo)簽的相對路徑用
lines = MdFileUtil.readLines(file);// 讀取MD源文件
} else {// 將Markdown文件內(nèi)容復(fù)制到導(dǎo)入頁面的文本塊中上傳到服務(wù)后臺,值為web
lines = MdFileUtil.readLines(bean.content);
}
Map<Integer, MdImageMarkBean> imgs = MdFileUtil.readImageLines(lines);// 獲取MD源文件中的圖片標(biāo)記
for (MdImageMarkBean img : imgs.values()) {
String url = img.getUrl();
String type = img.getType();
String tip = img.getTip();
String location = img.getLocation();
Attachment attachment = null;
if ("web".equals(location)) {// 如果是網(wǎng)絡(luò)圖片就導(dǎo)入到系統(tǒng)的附件中
attachment = attachmentService.importWebAttachment(user, url, type, tip);// 導(dǎo)入附件
} else {// 處理本地圖片讲冠,圖片標(biāo)簽一般是這樣的: ![檢查防火墻狀態(tài)](images/檢查防火墻狀態(tài).png)
url = fileDir + System.getProperty("file.separator") + url; // 轉(zhuǎn)換成服務(wù)器上的絕對文件路徑
attachment = attachmentService.importLocalAttachment(user, url, tip);// 導(dǎo)入附件
}
long attachmentId = attachment.id;
img.setAttachmentId(attachmentId);
String articleImage = "![" + tip + "](" + "/files/attachments/" + attachmentId + "/l)";
img.setImageMark(articleImage);// 替換圖片標(biāo)記為iTranswarp附件格式
if (article.imageId == 0) {// 導(dǎo)入的Article的封面圖片使用文章的第一張圖片
article.imageId = attachmentId;
}
}
// 更新原 MD 文件中的圖片標(biāo)記瓜客,并將所有的文章內(nèi)容合并到一個字符串中
StringBuffer sb = new StringBuffer();
for (int i = 0; i < lines.size(); i++) {// 替換MD文件內(nèi)容中的圖片標(biāo)簽
if (imgs.containsKey(i)) {
lines.set(i, imgs.get(i).getImageMark());
}
sb.append(lines.get(i)).append(System.getProperty("line.separator"));// 合并更新了圖片標(biāo)記后的每一行
}
article.id = IdUtil.nextId();
article.userId = user.id;
article.categoryId = bean.categoryId;
article.name = bean.name;
article.description = bean.description;
article.publishAt = bean.publishAt;
article.tags = bean.tags;
article.textId = textService.createText(sb.toString()).id;
this.db.insert(article);
return article;
}
4.3.2 擴(kuò)展 ApiController
為其添加方法 articleImportSource 和 articleImportLocal,供前端 ManageController 調(diào)用服務(wù)用竿开。
/**
* 從網(wǎng)絡(luò)Markdown源文件導(dǎo)入
* @param bean
* @return
*/
@PostMapping("/articles/import/source")
@RoleWith(Role.CONTRIBUTOR)
public Article articleImportSource(@RequestBody ArticleBean bean) {
Article article = null;
try {
article = this.articleService.importArticle(HttpContext.getRequiredCurrentUser(), bean, "web");
} catch (Exception e) {
e.printStackTrace();
}
if (article != null) {
this.articleService.deleteArticlesFromCache(article.categoryId);
}
return article;
}
/**
* 從服務(wù)器本地 Markdown 文件導(dǎo)入谱仪,需要事先將 Markdown 文件scp到服務(wù)器上,用來維護(hù)現(xiàn)有離線文章的
* @param bean
* @return
*/
@PostMapping("/articles/import/local")
@RoleWith(Role.CONTRIBUTOR)
public Article articleImportLocal(@RequestBody ArticleBean bean) {
//bean.content 是借用來存儲服務(wù)器上Markdown文件的絕對位置的否彩,例如:/Users/kevin/temp/test.md
Article article = null;
try {
article = this.articleService.importArticle(HttpContext.getRequiredCurrentUser(), bean, "local");
} catch (Exception e) {
e.printStackTrace();
}
if (article != null) {
this.articleService.deleteArticlesFromCache(article.categoryId);
}
return article;
}
4.3.3 擴(kuò)展 ManageController
提供兩個方法 articleImportSource 和 articleImportLocal疯攒,連接前端頁面和后端服務(wù)。
注意其中的 "action" 就是傳遞到頁面供導(dǎo)入文章用的 rest 服務(wù)地址列荔。
/**
* 從網(wǎng)絡(luò) Markdown 源文件的導(dǎo)入敬尺,特點(diǎn)是文章中的圖片存儲在網(wǎng)絡(luò)上
* @return
*/
@GetMapping("/article/article_import_source")
public ModelAndView articleImportSource() {
return prepareModelAndView("manage/article/article_import_source_form.html", Map.of("id", 0, "action", "/api/articles/import/source"));
}
/**
* 從服務(wù)器本地 Markdown 文件導(dǎo)入枚尼,特點(diǎn)是文章中的圖片在本地
* @return
*/
@GetMapping("/article/article_import_local")
public ModelAndView articleImportLocal() {
return prepareModelAndView("manage/article/article_import_local_form.html", Map.of("id", 0, "action", "/api/articles/import/local"));
}
4.3.4 擴(kuò)展頁面
在文章列表頁面,添加兩個導(dǎo)入按鈕砂吞,通過其 url 將其連接到兩個導(dǎo)入頁面:article_import_local_form.html 和 article_import_source_form.html署恍。
<div class="uk-margin">
<a href="javascript:refresh()" class="uk-button"><i class="uk-icon-refresh"></i> {{ _('Refresh') }}</a>
<a href="article_create" class="uk-button uk-button-primary uk-float-right"><i class="uk-icon-plus"></i>{{ _('New Article') }}</a>
<a href="article_import_source" class="uk-button uk-button-primary uk-float-right"><i class="uk-icon-plus"></i>{{ _('Import Article') }}</a>
<a href="article_import_local" class="uk-button uk-button-primary uk-float-right"><i class="uk-icon-plus"></i>{{ _('Import Local Article') }}</a>
</div>
新建的兩個導(dǎo)入頁面,拷貝自 article_form.html 并略做修改呜舒。
4.4 導(dǎo)入 wiki
一般而言锭汛,大型創(chuàng)作笨奠,比如一整套教程袭蝗、一整本書,使用客戶端本地創(chuàng)作還是相對更方便般婆,比如使用 gitbook 管理書籍到腥,使用 Typora 以 Markdown 格式書寫內(nèi)容,圖片等處理都非常順手蔚袍。
所以就誕生了將 gitbook 寫好的一整本書導(dǎo)入到 wiki 中來的需求:
- 離線寫好 gitbook乡范;
- 在系統(tǒng)內(nèi)創(chuàng)建 wiki;
- 將 gitbook 的所有文件上傳到服務(wù)器某個目錄下啤咽;
- 系統(tǒng)提供界面晋辆,填寫 gitbook 所在的文件路徑,然后導(dǎo)入宇整。
4.4.1 GitbookSummaryUtil 目錄工具類
gitbook 使用 SUMMARY.md
文件來管理書籍目錄瓶佳,所以對 gitbook 的導(dǎo)入,主要就是處理這個目錄文件內(nèi)容鳞青。
首先創(chuàng)建目錄行值對象類 GitbookSummaryBean
霸饲,存儲目錄行,并記錄父目錄信息臂拓。
public class GitbookSummaryBean {
private String content;// 內(nèi)容:“8個空格* [1.2.1 在路上](第01章 萬事開頭難/1.2.1onTheWay.md)”厚脉,3級內(nèi)容
private String title;// 顯示用的標(biāo)題:“1.2.1 在路上”
private String markdownFile;// 文件地址:“第01章 萬事開頭難/1.2.1onTheWay.md”
private int level;// 當(dāng)前頁面所處的級別:3級
private int displayOrder;// 同層頁面顯示序號:0
private long id;// 當(dāng)前頁面的編碼,導(dǎo)入wiki page后胶惰,就是數(shù)據(jù)庫內(nèi)的編碼
private GitbookSummaryBean parent;// 當(dāng)前這個目錄文件的父文件傻工,就是掛靠目錄樹用的
按順序讀取 SUMMARY.md
文件,將其存入 List 中孵滞,重點(diǎn)是同層序號 displayOrder 和目錄的父目錄的設(shè)定精钮。
/**
* 從Gitbook的summary文件中讀取文章目錄結(jié)構(gòu),不支持文件內(nèi)頁面錨點(diǎn)
*
* @param file
* @return
* @throws FileNotFoundException
* @throws IOException
*/
public static List<GitbookSummaryBean> readLines(File file) throws FileNotFoundException, IOException {
try (BufferedReader bf = new BufferedReader(new FileReader(file))) {
List<GitbookSummaryBean> summary = new ArrayList<GitbookSummaryBean>();
String line;
int[] displayOrders = new int[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };// 最多支持10級目錄剃斧,第0級不用
GitbookSummaryBean[] parents = new GitbookSummaryBean[] { null, null, null, null, null, null, null, null, null, null, null };// 最多支持10級目錄轨香,第0級不用
int preLineLevel = 1;// 前一行的級別
int curLineLevel = 1;// 當(dāng)前行的級別
// 按行讀取字符串
while ((line = bf.readLine()) != null) {
String tempLine = line;
if (!tempLine.trim().startsWith("* [")) {// 跳過不是目錄的行
continue;
}
GitbookSummaryBean bean = new GitbookSummaryBean();
bean.setContent(line);
bean.setTitle(line.substring(line.indexOf("* [") + 3, line.indexOf("](")));
bean.setMarkdownFile(line.substring(line.indexOf("](") + 2, line.lastIndexOf(")")));
// 解析line到bean:“8個空格* [1.2.1 在路上](第01章 萬事開頭難/1.2.1onTheWay.md)”
if (line.startsWith("* [")) {// 一級內(nèi)容,如“* [第01章 萬事開頭難](第01章 萬事開頭難/Start.md)”
curLineLevel = 1;
} else {// 非一級內(nèi)容幼东,用空格解析
String s = line.substring(0, line.indexOf("* ["));// 左邊的空格
curLineLevel = s.length() / 4 + 1;
}
bean.setLevel(curLineLevel);// 當(dāng)前目錄行級別
bean.setParent(parents[curLineLevel]);// 當(dāng)前頁面的父頁面
parents[curLineLevel + 1] = bean; // 當(dāng)前頁面就是后續(xù)下級頁面的父頁面
if (curLineLevel == preLineLevel) {// 同級
bean.setDisplayOrder(displayOrders[curLineLevel]);
displayOrders[curLineLevel] = displayOrders[curLineLevel] + 1;// 當(dāng)前編號+1臂容,為下一行做準(zhǔn)備
}
if (curLineLevel > preLineLevel) {// 向下降級
bean.setDisplayOrder(0);// 重新編號
displayOrders[curLineLevel] = displayOrders[curLineLevel] + 1;// 當(dāng)前編號+1科雳,為下一行做準(zhǔn)備
}
if (curLineLevel < preLineLevel) {// 向上升級,沿用既有編號
for (int i = curLineLevel; i < displayOrders.length - 1; i++) {// 將當(dāng)前級以下的全部置0脓杉,重新編號
displayOrders[i + 1] = 0;
parents[curLineLevel] = null;
}
bean.setDisplayOrder(displayOrders[curLineLevel]);
}
summary.add(bean);
preLineLevel = curLineLevel;// 下一行的前一行就是當(dāng)前行
}
return summary;
}
}
4.4.2 擴(kuò)展 WikiService 類
在 wiki 服務(wù)類中增加 importWiki
方法:創(chuàng)建帶父子關(guān)系的 wiki page糟秘,并將 Markdown 文件內(nèi)容導(dǎo)入page 中,其中的圖片球散,導(dǎo)入系統(tǒng)附件尿赚。
@Transactional
public Wiki importWiki(User user, WikiImportBean bean) throws Exception {
long wikiId = bean.wikiId;
Wiki wiki = this.getById(wikiId);
String fileName = bean.gitbookPath + System.getProperty("file.separator") + "SUMMARY.md";
List<GitbookSummaryBean> list = GitbookSummaryUtil.readLines(new File(fileName));
for (GitbookSummaryBean summary: list) {
long parentId;
if (summary.getParent() == null) { // 沒有父節(jié)點(diǎn)的是“第1章”這樣的,直接掛到wiki下
parentId = wikiId;
} else {
parentId = summary.getParent().getId();
}
//處理頁面文件中的附件(圖片)
String pageFile = bean.gitbookPath + System.getProperty("file.separator") + summary.getMarkdownFile();
List<String> lines = MarkdownFileUtil.readLines(new File(pageFile));//頁面內(nèi)容
Map<Integer, MarkdownImageBean> imgs = MarkdownFileUtil.readImageLines(lines);// 獲取MD源文件中的圖片標(biāo)記
for (MarkdownImageBean img : imgs.values()) {
String url = img.getUrl();
String type = img.getType();
String tip = img.getTip();
String location = img.getLocation();
Attachment attachment = null;
if ("web".equals(location)) {// 如果是網(wǎng)絡(luò)圖片就導(dǎo)入到系統(tǒng)的附件中
attachment = attachmentService.importWebAttachment(user, url, type, tip);// 導(dǎo)入附件
} else {// 處理本地圖片蕉堰,圖片標(biāo)簽一般是這樣的: ![檢查防火墻狀態(tài)](images/檢查防火墻狀態(tài).png)
url = pageFile.subSequence(0, pageFile.lastIndexOf("/") + 1) + url; // 轉(zhuǎn)換成服務(wù)器上的絕對文件路徑
attachment = attachmentService.importLocalAttachment(user, url, tip);// 導(dǎo)入附件
}
long attachmentId = attachment.id;
img.setAttachmentId(attachmentId);
String imageMark = "![" + tip + "](" + "/files/attachments/" + attachmentId + "/l)";
img.setImageMark(imageMark);// 替換圖片標(biāo)記為iTranswarp附件格式
}
// 更新頁面文件中的圖片標(biāo)記凌净,并將所有的頁面內(nèi)容合并到一個字符串中
StringBuffer sbPage = new StringBuffer();
for (int i = 0; i < lines.size(); i++) {// 替換MD文件內(nèi)容中的圖片標(biāo)簽
if (imgs.containsKey(i)) {
lines.set(i, imgs.get(i).getImageMark());
}
sbPage.append(lines.get(i)).append(System.getProperty("line.separator"));// 合并更新了圖片標(biāo)記后的每一行
}
WikiPage page = new WikiPage();
page.wikiId = wikiId;
page.parentId = parentId;
page.name = summary.getTitle();
page.publishAt = wiki.publishAt;//使用wiki的發(fā)布時間,一家人就是要整整齊齊嘛
page.textId = textService.createText(sbPage.toString()).id;
page.displayOrder = summary.getDisplayOrder();
this.db.insert(page);
summary.setId(page.id);//供后續(xù)獲取父頁面id用
}
return wiki;
}
4.4.3 擴(kuò)展 ApiController 類
首先創(chuàng)建 WikiImportBean 值對象屋讶,用來存儲從頁面上傳遞回控制器的信息冰寻。
public class WikiImportBean extends AbstractRequestBean {
public String gitbookPath;
public long wikiId;
public long publishAt;
public String content = "New wiki page content";//默認(rèn)wiki頁面的內(nèi)容
@Override
public void validate(boolean createMode) {
}
}
在 ApiController 類中添加 wikiImport 方法,將 gitbook 導(dǎo)入系統(tǒng)皿渗。
@PostMapping("/wikiImport")
@RoleWith(Role.EDITOR)
public Wiki wikiImport(@RequestBody WikiImportBean bean) {
Wiki wiki = wikiService.getById(bean.wikiId);
try {
wiki = this.wikiService.importWiki(HttpContext.getRequiredCurrentUser(), bean);
} catch (Exception e) {
e.printStackTrace();
}
this.wikiService.removeWikiFromCache(wiki.id);
return wiki;
}
4.4.3 修改 wiki_list.html 頁面
在 wiki 行的操作列添加一個按鈕斩芭,接收 gitbook 在服務(wù)器上的文件路徑,然后調(diào)用后臺方法乐疆,導(dǎo)入 wiki:
importBook: function (w) {// 從gitbook導(dǎo)入wiki
var now = Date.now();
UIkit.modal.prompt("{{ _('Gitbook在服務(wù)器上的位置') }}:", "{{ _('/Users/kevin/temp/demobook') }}", function (path) {
postJSON('/api/wikiImport', {
wikiId: w.id,
gitbookPath: path,
publishAt: now,
content: 'New wiki page content'
}, function(err, result) {
if (err) {
showError(err);
return;
}
});
});
}
4.5 用戶維護(hù)
個人網(wǎng)站划乖,為了避免內(nèi)容審核維護(hù)工作,初期不提供用戶評論功能挤土。
所以琴庵,需要增加本地用戶維護(hù)功能:新增用戶和用戶密碼修改。
用戶通過頁面登錄時耕挨,傳遞到后端的密碼不是明文细卧,而是加密處理過的值,如下:
<script src="/static/js/3rdparty/sha256.js"></script>
$('#hashPasswd').val(sha256.hmac(email, pwd));
新增 user_form.html 文件筒占,提供用戶注冊功能贪庙,配套提供后臺服務(wù)代碼。
修改 user_list.html 文件翰苫,提供用戶密碼修改功能止邮,同樣修改后臺服務(wù)代碼。
4.6 小結(jié)
導(dǎo)入文章和導(dǎo)入 wiki 這兩個功能使用頻率不高奏窑,所以頁面及后臺代碼并沒有做太多的設(shè)計(jì)导披,夠用就好。
經(jīng)測試埃唯,一本 500+ 頁的 gitbook 導(dǎo)入撩匕,在我的開發(fā)筆記本上導(dǎo)入時間小于30秒。
Kevin墨叛,2020年5月20日止毕,成都模蜡。