1. 根本介绍

博客最根本的性能就是让作者可能自在公布本人的文章,分享本人观点,记录学习的过程。Halo 为用户提供了公布文章和展现自定义页面的性能,上面咱们剖析一下这些性能的实现过程。

2. 管理员公布文章

Halo 我的项目中,文章和页面的实体类别离为 Post 和 Sheet,二者都是 BasePost 的子类。BasePost 对应数据库中的 posts 表,posts 表既存储了文章的数据,又存储了页面的数据,那么我的项目中是如何辨别文章和页面的呢?上面是 BasePost 类的源码(仅展现局部代码):

@Data@Entity(name = "BasePost")@Table(name = "posts", indexes = {    @Index(name = "posts_type_status", columnList = "type, status"),    @Index(name = "posts_create_time", columnList = "create_time")})@DiscriminatorColumn(name = "type", discriminatorType = DiscriminatorType.INTEGER,    columnDefinition = "int default 0")@ToString(callSuper = true)@EqualsAndHashCode(callSuper = true)public class BasePost extends BaseEntity {    @Id    @GeneratedValue(strategy = GenerationType.IDENTITY, generator = "custom-id")    @GenericGenerator(name = "custom-id", strategy = "run.halo.app.model.entity.support"        + ".CustomIdGenerator")    private Integer id;    /**     * Post title.     */    @Column(name = "title", nullable = false)    private String title;    /**     * Post status.     */    @Column(name = "status")    @ColumnDefault("1")    private PostStatus status;    // 此处省略局部代码}

咱们晓得,Halo 应用 JPA 来创立数据表、存储和获取表中的信息。上述代码中,注解 @DiscriminatorColumn 是之前文章中没有介绍过的,@DiscriminatorColumn 属于 JPA 注解,它的作用是当多个实体类对应同一个数据表时,可应用一个字段进行辨别。name 指定该字段的名称,discriminatorType 是该字段的类型,columnDefinition 设置该字段的默认值。由此可知,字段 type 是辨别文章和页面的根据,上面是 Post 类和 Sheet 类的源码:

// Post@Entity(name = "Post")@DiscriminatorValue(value = "0")public class Post extends BasePost {}// Sheet@Entity(name = "Sheet")@DiscriminatorValue("1")public class Sheet extends BasePost {}

Post 和 Sheet 都没有定义额定的属性,二者的区别仅在于注解 @DiscriminatorValue 设置的 value 不同。上文提到,@DiscriminatorColumn 指明了用作辨别的字段,而 @DiscriminatorValue 的作用就是指明该字段的具体值。也就是说,type 为 0 时示意文章,为 1 时示意页面。另外,这里也简略介绍一下 @Index 注解,该注解用于申明表中的索引,例如 @Index(name = "posts_type_status", columnList = "type, status") 示意在 posts 表中创立 type 和 status 的复合索引,索引名称为 posts_type_status。

上面咱们持续剖析文章是如何公布的,首先进入到管理员界面,而后点击 "文章" -> "写文章",之后就能够填写内容了:

为了全面理解文章公布的过程,咱们尽量将能填的信息都填上。点击 "公布",触发 /api/admin/posts 申请:

api/admin/posts 申请在 PostController 中定义:

@PostMapping@ApiOperation("Creates a post")public PostDetailVO createBy(@Valid @RequestBody PostParam postParam,    @RequestParam(value = "autoSave", required = false, defaultValue = "false") Boolean autoSave) {    // 将 PostParam 对象转化为 Post 对象    // Convert to    Post post = postParam.convertTo();    // 依据参数创立文章    return postService.createBy(post, postParam.getTagIds(), postParam.getCategoryIds(),        postParam.getPostMetas(), autoSave);}

该申请接管两个参数,即 postParam 和 autoSave。postParam 存储咱们填写的文章信息,autoSave 是与系统日志无关的参数,默认为 false。服务器收到申请后,首先将接管到的 PostParam 对象转化为 Post 对象,而后从 PostParam 中提取出文章标签、分类、元数据等,接着调用 createBy 办法创立文章,createBy 办法的解决逻辑为:

public PostDetailVO createBy(Post postToCreate, Set<Integer> tagIds, Set<Integer> categoryIds,    Set<PostMeta> metas, boolean autoSave) {    // 创立或更新文章    PostDetailVO createdPost = createOrUpdate(postToCreate, tagIds, categoryIds, metas);    if (!autoSave) {        // 记录系统日志        // Log the creation        LogEvent logEvent = new LogEvent(this, createdPost.getId().toString(),            LogType.POST_PUBLISHED, createdPost.getTitle());        eventPublisher.publishEvent(logEvent);    }    return createdPost;}

该办法会调用 createOrUpdate 办法创立文章,因为 autoSave 为 false,所以文章创立实现后会记录一条对于文章公布的系统日志。持续进入 createOrUpdate 办法,查看创立文章的具体过程:

private PostDetailVO createOrUpdate(@NonNull Post post, Set<Integer> tagIds,        Set<Integer> categoryIds, Set<PostMeta> metas) {    Assert.notNull(post, "Post param must not be null");    // 查看创立或更新的文章是否为私密文章    // Create or update post    Boolean needEncrypt = Optional.ofNullable(categoryIds)        .filter(HaloUtils::isNotEmpty)        .map(categoryIdSet -> {            for (Integer categoryId : categoryIdSet) {                // 文章分类是否设有明码                if (categoryService.categoryHasEncrypt(categoryId)) {                    return true;                }            }            return false;        }).orElse(Boolean.FALSE);    // 如果文章的明码不为空或者所属分类设有明码, 那么该文章就属于私密文章    // if password is not empty or parent category has encrypt, change status to intimate    if (post.getStatus() != PostStatus.DRAFT        && (StringUtils.isNotEmpty(post.getPassword()) || needEncrypt)    ) {        post.setStatus(PostStatus.INTIMATE);    }    // 格式化文章的内容, 查看文章的别名是否有反复, 都没问题就创立文章    post = super.createOrUpdateBy(post);    // 移除文章原先绑定的标签    postTagService.removeByPostId(post.getId());    // 移除文章原先绑定的分类    postCategoryService.removeByPostId(post.getId());    // 新设置的标签    // List all tags    List<Tag> tags = tagService.listAllByIds(tagIds);    // 新设置的分类    // List all categories    List<Category> categories = categoryService.listAllByIds(categoryIds, true);    // Create post tags    List<PostTag> postTags = postTagService.mergeOrCreateByIfAbsent(post.getId(),        ServiceUtils.fetchProperty(tags, Tag::getId));    log.debug("Created post tags: [{}]", postTags);    // Create post categories    List<PostCategory> postCategories =        postCategoryService.mergeOrCreateByIfAbsent(post.getId(),            ServiceUtils.fetchProperty(categories, Category::getId));    log.debug("Created post categories: [{}]", postCategories);    // 移除文章原有的元数据并将新设置的元数据绑定到该文章    // Create post meta data    List<PostMeta> postMetaList = postMetaService        .createOrUpdateByPostId(post.getId(), metas);    log.debug("Created post metas: [{}]", postMetaList);    // 当文章创立或更新时革除对所有客户端的受权    // Remove authorization every time an post is created or updated.    authorizationService.deletePostAuthorization(post.getId());    // 返回文章的信息, 便于管理员界面展现    // Convert to post detail vo    return convertTo(post, tags, categories, postMetaList);}
  1. 查看以后创立或更新的文章是否为私密文章,如果文章设有明码,或者文章所属的分类设有明码,那么就将文章的状态改为 INTIMATE,示意该文章属于私密文章。
  2. 对文章的内容进行格式化,也就是将原始内容转化为可能在前端展现的带有 HTML 标签的内容。而后查看文章的别名是否有反复,查看无误后在 posts 表中创立该文章。
  3. 移除文章原先绑定的所有标签和分类。
  4. 为文章从新绑定标签和分类,以标签为例,绑定的逻辑为:首先查问出文章原先绑定的标签,记为汇合 A,而后将新设置的标签记为汇合 B,之后在 post_tags 表中删除汇合 A 中存在但汇合 B 中不存在的记录,并创立汇合 B 中不存在而汇合 A 中存在的记录。步骤 4 其实和步骤 3 是有抵触的,因为步骤 3 将文章原先绑定的标签删除了,所以汇合 A 中的元素总是为 0,实际上步骤 3 中的操作是多余的,能够将其正文掉。
  5. 移除文章原有的元数据,将新设置的元数据绑定到该文章。
  6. 删除对所有客户端的文章受权,文章受权是针对私密文章设置的,在下节中咱们会剖析一下文章受权的作用。
  7. 返回文章的具体信息,供管理员页面展现。

执行完以上步骤,一篇文章就创立或更新实现了:

3. 用户端拜访文章

本节介绍用户(普通用户,非管理员)拜访文章的具体过程,上文中,咱们在创立文章时为文章设置了明码,因而该文章属于私密文章。Halo 为私密文章设置了的 "受权" 机制,受权指的是当用户首次拜访私密文章时,须要填写拜访明码,服务器收到申请后,会检查用户的明码是否正确。如果正确,那么服务端会对客户端进行受权,这样当用户在短时间内再次拜访该文章时能够不必反复输出明码。

默认状况下私密文章是不会在博客首页展现的,为了测试,咱们批改 PostModel 中的 list 办法,首先将上面的代码正文掉:

Page<Post> postPage = postService.pageBy(PostStatus.PUBLISHED, pageable);

而后新增如下代码:

PostQuery query = new PostQuery();query.setStatuses(new HashSet<>(Arrays.asList(PostStatus.PUBLISHED, PostStatus.INTIMATE)));Page<Post> postPage = postService.pageBy(query, pageable);

这样,博客主页就能够展现状态为 "已公布" 和 "私密" 的文章了:

点击 "我的第一篇文章",触发 /archives/first 申请,first 为文章的别名 slug,该申请由 ContentContentController 的 content 办法解决(仅展现局部代码):

@GetMapping("{prefix}/{slug}")public String content(@PathVariable("prefix") String prefix,    @PathVariable("slug") String slug,    @RequestParam(value = "token", required = false) String token,    Model model) {    PostPermalinkType postPermalinkType = optionService.getPostPermalinkType();    if (optionService.getArchivesPrefix().equals(prefix)) {        if (postPermalinkType.equals(PostPermalinkType.DEFAULT)) {            // 依据 slug 查问出文章的            Post post = postService.getBySlug(slug);            return postModel.content(post, token, model);        }        // 省略局部代码    }}

上述办法首先依据 slug 查问出 title 为 "我的第一篇文章" 的文章,而后调用 postModel.content 办法封装文章的信息,postModel.content 办法的解决逻辑如下:

public String content(Post post, String token, Model model) {    // 文章在回收站    if (PostStatus.RECYCLE.equals(post.getStatus())) {        // Articles in the recycle bin are not allowed to be accessed.        throw new NotFoundException("查问不到该文章的信息");    } else if (StringUtils.isNotBlank(token)) {        // If the token is not empty, it means it is an admin request,        // then verify the token.        // verify token        String cachedToken = cacheStore.getAny(token, String.class)            .orElseThrow(() -> new ForbiddenException("您没有该文章的拜访权限"));        if (!cachedToken.equals(token)) {            throw new ForbiddenException("您没有该文章的拜访权限");        }        // 手稿    } else if (PostStatus.DRAFT.equals(post.getStatus())) {        // Drafts are not allowed bo be accessed by outsiders.        throw new NotFoundException("查问不到该文章的信息");        //    } else if (PostStatus.INTIMATE.equals(post.getStatus())        && !authenticationService.postAuthentication(post, null)    ) {        // Encrypted articles must has the correct password before they can be accessed.        model.addAttribute("slug", post.getSlug());        model.addAttribute("type", EncryptTypeEnum.POST.getName());        // 如果激活的主题定义了输出明码页面        if (themeService.templateExists(POST_PASSWORD_TEMPLATE + SUFFIX_FTL)) {            return themeService.render(POST_PASSWORD_TEMPLATE);        }        // 进入输出明码页面        return "common/template/" + POST_PASSWORD_TEMPLATE;    }    post = postService.getById(post.getId());    if (post.getEditorType().equals(PostEditorType.MARKDOWN)) {        post.setFormatContent(MarkdownUtils.renderHtml(post.getOriginalContent()));    } else {        post.setFormatContent(post.getOriginalContent());    }    postService.publishVisitEvent(post.getId());    postService.getPrevPost(post).ifPresent(        prevPost -> model.addAttribute("prevPost", postService.convertToDetailVo(prevPost)));    postService.getNextPost(post).ifPresent(        nextPost -> model.addAttribute("nextPost", postService.convertToDetailVo(nextPost)));    List<Category> categories = postCategoryService.listCategoriesBy(post.getId(), false);    List<Tag> tags = postTagService.listTagsBy(post.getId());    List<PostMeta> metas = postMetaService.listBy(post.getId());    // Generate meta keywords.    if (StringUtils.isNotEmpty(post.getMetaKeywords())) {        model.addAttribute("meta_keywords", post.getMetaKeywords());    } else {        model.addAttribute("meta_keywords",            tags.stream().map(Tag::getName).collect(Collectors.joining(",")));    }    // Generate meta description.    if (StringUtils.isNotEmpty(post.getMetaDescription())) {        model.addAttribute("meta_description", post.getMetaDescription());    } else {        model.addAttribute("meta_description",            postService.generateDescription(post.getFormatContent()));    }    model.addAttribute("is_post", true);    model.addAttribute("post", postService.convertToDetailVo(post));    model.addAttribute("categories", categoryService.convertTo(categories));    model.addAttribute("tags", tagService.convertTo(tags));    model.addAttribute("metas", postMetaService.convertToMap(metas));    if (themeService.templateExists(        ThemeService.CUSTOM_POST_PREFIX + post.getTemplate() + SUFFIX_FTL)) {        return themeService.render(ThemeService.CUSTOM_POST_PREFIX + post.getTemplate());    }    return themeService.render("post");}
  1. 如果文章状态为 "草稿" 或 "位于回收站",那么向前端反馈无文章信息。如果申请的 Query 中存在 token,那么该申请为一个 admin 申请(与管理员在后盾浏览文章时发送的申请是相似的,只不过在管理员界面拜访文章时 token 存储在申请的 Header 中,这里的 token 存储在申请的 Query 参数中),此时查看 token 是否无效。如果文章状态为 "私密" 且客户端并未取得 "受权",那么重定向到明码输出页面,否则执行上面的步骤。
  2. 对文章内容进行格式化,记录文章被拜访的系统日志。
  3. 在 model 中封装文章的内容、标签、分类、元数据、前一篇文章、后一篇文章等信息,而后利用 FreeMaker 基于 post.ftl 文件(已激活主题的)生成 HTML 页面。

对于 "已公布" 和 "已取得受权" 的文章,申请解决实现后用户可间接看到文章的内容。因为 "我的第一篇文章" 属于私密文章且并未对用户进行受权,因而页面产生了重定向:

输出明码后,点击 "验证",触发 content/post/first/authentication 申请,该申请由 ContentContentController 的 password 办法解决:

@PostMapping(value = "content/{type}/{slug:.*}/authentication")@CacheLock(traceRequest = true, expired = 2)public String password(@PathVariable("type") String type,    @PathVariable("slug") String slug,    @RequestParam(value = "password") String password) throws UnsupportedEncodingException {    String redirectUrl;    // 如果 type 为 post    if (EncryptTypeEnum.POST.getName().equals(type)) {        // 受权操作        redirectUrl = doAuthenticationPost(slug, password);    } else if (EncryptTypeEnum.CATEGORY.getName().equals(type)) {        redirectUrl = doAuthenticationCategory(slug, password);    } else {        throw new UnsupportedException("未知的加密类型");    }    return "redirect:" + redirectUrl;}

因为 URL 中的 type 为 post(咱们拜访的是文章),因而由 doAuthenticationPost 办法为客户端受权:

private String doAuthenticationPost(    String slug, String password) throws UnsupportedEncodingException {    Post post = postService.getBy(PostStatus.INTIMATE, slug);    post.setSlug(URLEncoder.encode(post.getSlug(), StandardCharsets.UTF_8.name()));    authenticationService.postAuthentication(post, password);    BasePostMinimalDTO postMinimalDTO = postService.convertToMinimal(post);    StringBuilder redirectUrl = new StringBuilder();    if (!optionService.isEnabledAbsolutePath()) {        redirectUrl.append(optionService.getBlogBaseUrl());    }    redirectUrl.append(postMinimalDTO.getFullPath());    return redirectUrl.toString();}

上述代码中,authenticationService.postAuthentication(post, password); 是为客户端进行受权操作的,受权实现后服务器会将申请重定向到 /archives/first,如果受权胜利那么用户就能够看到文章的内容,如果受权失败那么依然处于 "明码输出" 页面。进入到 postAuthentication 办法查看受权的具体过程(省略局部代码):

public boolean postAuthentication(Post post, String password) {    // 从 cacheStore 中查问出以后客户端已取得受权的文章 id    Set<String> accessPermissionStore = authorizationService.getAccessPermissionStore();    // 如果文章的明码不为空    if (StringUtils.isNotBlank(post.getPassword())) {        // 如果曾经受过权        if (accessPermissionStore.contains(AuthorizationService.buildPostToken(post.getId()))) {            return true;        }        // 如果明码正确就为客户端受权        if (post.getPassword().equals(password)) {            authorizationService.postAuthorization(post.getId());            return true;        }        return false;    }    // 省略局部代码}
  1. 首先利用 cacheStore 查问出以后客户端已取得受权的文章 id,cacheStore 是一个以 ConcurrentHashMap 为容器的外部缓存,该操作指的是从缓存中查问出 key 为 "ACCESS_PERMISSION: sessionId" 的 value(一个 Set 汇合),其中 sessionId 是以后 session 的 id。咱们在前一篇文章中介绍过 Halo 中的 3 个过滤器,用户端(非管理员)的所有申请都会被 ContentFilter 拦挡,且每次拦挡后都会执行 doAuthenticate 办法,该办法中的 request.getSession(true); 保障了服务端肯定会创立一个 session。session 创立实现后存储在服务端,默认状况下服务端会为客户端调配一个非凡的 cookie,名称为 "JSESSIONID",其存储的值就是 session 的 id。之后客户端发送申请时,服务端能够通过申请中的 cookie 查问出存储的 session,并依据 session 确定客户端的身份。因而能够应用 "ACCESS_PERMISSION: sessionId" 作为 key 来保留以后客户端已取得受权的文章 id。
  2. 判断文章的明码是否为空,不为空就示意文章自身属于私密文章,为空示意文章属于设有明码的分类。因为文章设有明码,所以继续执行上面的步骤。
  3. 查看 accessPermissionStore 中(步骤 1 查问出的 Set 汇合)是否蕴含正在拜访的文章的 id。如果蕴含,那么就示意已为以后客户端受权过,不蕴含的话继续执行上面的步骤。
  4. 判断用户输出的明码与文章的明码是否雷同,雷同的话就为客户端受权,也就是在 accessPermissionStore 中存储以后文章的 id。

受权胜利后,客户端再次拜访该私密文章时,服务器能够依据 cookie 失去 sessionId,而后从 cacheStore 中查问出 key 为 "ACCESS_PERMISSION: sessionId" 的 value,判断 value 中是否蕴含以后文章的 id。如果蕴含,那么就示意客户端曾经取得了文章的拜访权限,服务器可向前端返回文章内容,这个过程对应的是前文中 postModel.content 办法的解决逻辑。

客户端(浏览器)保留的cookie:

客户端拜访文章时申请中携带 cookie:

理解了 Halo 中的 "文章受权" 机制后,咱们就能明确为什么服务器在创立或更新文章时会删除对所有客户端的受权。因为此时文章的信息产生了变动,明码也可能重置,因而客户端须要从新输出明码。联合前一篇文章咱们能够得出结论:管理员拜访管理员界面上的性能时,服务器依据 Request Headers 中的 token 来确定管理员的身份。普通用户拜访私密文章时,服务器依据 cookie 判断用户是否具备文章的拜访权限,cookie 和 token 是两种十分重要的身份认证形式。

4. 元数据

元数据指的是形容数据属性的信息,Halo 中可为文章设置元数据。创立或更新文章时,点击 "高级" 选项后即可增加元数据,默认的主题 caicai_anatole 没有提供可应用的元数据,所以咱们将更主题更换为 joe2.0(其它主题也可),为文章增加元数据:

上图中,咱们为 "我的第一篇文章" 增加了一个元数据,其中 meta_key 为 "enable_like",meta_value 为 "false",示意该文章不容许点赞。前文中介绍过,用户拜访文章时,服务器会将文章的信息进行封装,其中就包含文章的元数据,封装实现后,由 FreeMaker 基于 post.ftl 文件生成用于浏览的 HTML 页面。joe2.0 主题的 post.ftl 文件会依据 "enable_like" 的值决定是否显示点赞按钮。

文章 "Hello Halo" 能够点赞:

"我的第一篇文章" 不能够点赞:

除了 "enable_like",还能够设置文章是否反对 mathjax 以及设置文章中图片的宽度等。

5. 自定义页面

Halo 中除文章外,博主还能够对外分享自定义的页面,例如博客主页的 "对于页面" 就是零碎在初始化博客时为咱们创立的页面。页面和文章的创立、查看等操作是类似的,所以代码的具体执行过程就不再介绍了。页面创立实现后,在管理员界面的顺次点击 "外观" -> "菜单" -> "其余" -> "从零碎预设链接增加" -> "自定义页面" -> "增加" 即可在博客主页实现页面的展现: