內(nèi)容管理網(wǎng)站 iTranswarp 分析

iTranswarp 是廖雪峰大神官方網(wǎng)站的開源 CMS向挖,用來托管個人的網(wǎng)站月帝,簡潔夠用先馆。

4時如梭阱扬,4季如歌

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>&nbsp;&nbsp;
  <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 中來的需求:

  1. 離線寫好 gitbook乡范;
  2. 在系統(tǒng)內(nèi)創(chuàng)建 wiki;
  3. 將 gitbook 的所有文件上傳到服務(wù)器某個目錄下啤咽;
  4. 系統(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日止毕,成都模蜡。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市扁凛,隨后出現(xiàn)的幾起案子忍疾,更是在濱河造成了極大的恐慌,老刑警劉巖谨朝,帶你破解...
    沈念sama閱讀 218,755評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件卤妒,死亡現(xiàn)場離奇詭異,居然都是意外死亡字币,警方通過查閱死者的電腦和手機(jī)则披,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來纬朝,“玉大人收叶,你說我怎么就攤上這事骄呼」部粒” “怎么了?”我有些...
    開封第一講書人閱讀 165,138評論 0 355
  • 文/不壞的土叔 我叫張陵蜓萄,是天一觀的道長隅茎。 經(jīng)常有香客問我,道長嫉沽,這世上最難降的妖魔是什么辟犀? 我笑而不...
    開封第一講書人閱讀 58,791評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮绸硕,結(jié)果婚禮上堂竟,老公的妹妹穿的比我還像新娘。我一直安慰自己玻佩,他們只是感情好出嘹,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,794評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著咬崔,像睡著了一般税稼。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上垮斯,一...
    開封第一講書人閱讀 51,631評論 1 305
  • 那天郎仆,我揣著相機(jī)與錄音,去河邊找鬼兜蠕。 笑死扰肌,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的熊杨。 我是一名探鬼主播曙旭,決...
    沈念sama閱讀 40,362評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼墩剖,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了夷狰?” 一聲冷哼從身側(cè)響起岭皂,我...
    開封第一講書人閱讀 39,264評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎沼头,沒想到半個月后爷绘,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,724評論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡进倍,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年土至,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片猾昆。...
    茶點(diǎn)故事閱讀 40,040評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡陶因,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出垂蜗,到底是詐尸還是另有隱情楷扬,我是刑警寧澤,帶...
    沈念sama閱讀 35,742評論 5 346
  • 正文 年R本政府宣布贴见,位于F島的核電站烘苹,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏片部。R本人自食惡果不足惜镣衡,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,364評論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望档悠。 院中可真熱鬧廊鸥,春花似錦、人聲如沸辖所。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,944評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽奴烙。三九已至助被,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間切诀,已是汗流浹背揩环。 一陣腳步聲響...
    開封第一講書人閱讀 33,060評論 1 270
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留幅虑,地道東北人丰滑。 一個月前我還...
    沈念sama閱讀 48,247評論 3 371
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親褒墨。 傳聞我的和親對象是個殘疾皇子炫刷,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,979評論 2 355