这是博客平台系列的第三部分。如果你不知道我在说什么,一定要检查一下part 1part 2。对于这一部分,我终于写了一些有意义的代码,所以让我们直接进入它。

测试

我从编写一些测试开始,但受到以下启发James CoplienSimon Brown我决定在这个项目中放弃TDD和繁重的单元测试。(并不是说我已经皈依了,我反对单元测试。我只是想尝试一些新的东西。)相反,我编写了两个粗粒度测试来锻炼我的web端点。

def "home"() {
    expect:
    def result = mockMvc.perform(get("/"))
            .andExpect(status().isOk())
            .andExpect(view().name("home"))
            .andReturn()
    def posts = result.modelAndView.model.posts
    assertTestPost(posts[0], 2)
    assertTestPost(posts[1], 1)
}

def "post"(n) {
    expect:
    def result = mockMvc.perform(get("/post$n"))
            .andExpect(status().isOk())
            .andExpect(view().name("post"))
            .andReturn()
    assertTestPost(result.modelAndView.model.post, n)

    where:
    n << [1, 2]
}


如您所见,我使用MockMvc来期望一个视图,并在模型上执行断言。这是有充分理由的。如果有人将这个项目用于自己的博客,他应该能够以任何方式修改视图模板,而不会破坏测试。如果我对内容做出任何断言,我也无法保证。

在开发过程中,又出现了一个测试用例:丢失POST。

def "missing post"() {
    expect:
    mockMvc.perform(get("/surely-not-existent"))
            .andExpect(status().isNotFound())
            .andExpect(view().name("not-found"))
}


信不信由你,这3项测试给了我一个非常合理的覆盖范围。实际上,如果我们不计算处理IO检查的异常,这是100%,反正我想重新抛出这些异常。秘密就在测试帖子里assertTestPost()方法。

---
title: Post 1  
summary: Summary 1  
date: 1970-01-01  
---

**Content 1**


void assertTestPost(post, n) {  
    assert post.title == "Post $n"
    assert post.summary == "Summary $n"
    assert post.date.format(ISO_LOCAL_DATE) == "1970-01-0$n"
    assert post.url == "/post$n"
    assert post.content == "<p><strong>Content $n</strong></p>\n"
}


要传递这些断言,应用程序必须正确解析POST的Markdown。然而,这个例子非常简单,可以很容易地看到正在发生的事情。我没有在帖子里放任何复杂的降价内容,因为我的目的不是为了测试commonmark是为了测试一些已发生解析。

控制器

控制器从单一的方法发展到令人瞠目结舌的4个,其中一个是模型属性。

@Controller
public class PostController {

    @Value("${blog.name}")
    private String blogName;

    @Autowired
    private PostReader postReader;

    @ModelAttribute("blogName")
    public String getBlogName() {
        return blogName;
    }

    @RequestMapping("/")
    public String home(Model model) {
        model.addAttribute("posts", postReader.readAll());
        return "home";
    }

    @RequestMapping("/{path}")
    public String post(@PathVariable("path") String path, Model model) {
        model.addAttribute("post", postReader.readOne(path));
        return "post";
    }

    @ResponseStatus(HttpStatus.NOT_FOUND)
    @ExceptionHandler(PostReader.MissingPost.class)
    public String missingPost() {
        return "not-found";
    }
}


基本上,我们这里有3种情况:主页,帖子页面,或者当帖子丢失时找不到的页面。没什么特别的,所有的魔法似乎都在别的地方!

邮局读者

阅读我创建的PostReader用2个方法初始化:readAll()readOne()当前,它从类路径上的文件读取帖子。到目前为止还没有Git和缓存,但我打赌很快就会需要(并且已经完成)。

@Service
public class PostReader {

    @Value("${posts.location}")
    private String postsLocation;

    @Autowired
    private PathMatchingResourcePatternResolver resourceResolver;

    public List<MarkdownPost> readAll() {
        try {
            return Stream.of(resourceResolver.getResources(postLocation("*")))
                    .map(MarkdownPost::new)
                    .sorted(Comparator.comparing(MarkdownPost::getDate).reversed())
                    .collect(toList());
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public MarkdownPost readOne(String path) {
        Resource resource = resourceResolver.getResource(postLocation(path));
        if (!resource.exists()) {
            throw new MissingPost();
        }
        return new MarkdownPost(resource);
    }

    private String postLocation(String path) {
        return postsLocation + path + MarkdownPost.EXTENSION;
    }

    public static class MissingPost extends RuntimeException {
    }
}


正如您所看到的,这一切都归结为创建一个MarkdownPost从找到的资源。我用了丑陋的名字PathMatchingResourcePatternResolver,因为从JAR资源中加载文件并不简单,而且我没有太多的时间花在这上面。

下柱

我想知道怎么做这个。显然,post应该保存内容和元数据。我不确定它应该是一个愚蠢的数据保持器还是更聪明的东西。最后我选择了第二个选项,因为它更简单,看起来也更自然--最终,平台中的一个帖子是一个资源,我们通过解析来获取数据。这是:

public class MarkdownPost {  
    public static final String EXTENSION = ".md";

    private Node parsedResource;
    private Map<String, List<String>> metadata;
    private String url;

    public MarkdownPost(Resource resource) {
        try {
            this.parsedResource = parse(resource);
            this.metadata = extractMetadata(parsedResource);
            this.url = "/" + resource.getFilename().replace(EXTENSION, "");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private Node parse(Resource resource) throws IOException {
        Parser parser = Parser.builder().extensions(singletonList(YamlFrontMatterExtension.create())).build();
        return parser.parseReader(new InputStreamReader(resource.getInputStream()));
    }

    private Map<String, List<String>> extractMetadata(Node document) {
        YamlFrontMatterVisitor visitor = new YamlFrontMatterVisitor();
        document.accept(visitor);
        return visitor.getData();
    }

    public String getTitle() {
        return metadata.get("title").get(0);
    }

    public String getSummary() {
        return metadata.get("summary").get(0);
    }

    public LocalDate getDate() {
        return LocalDate.parse(metadata.get("date").get(0), DateTimeFormatter.ISO_LOCAL_DATE);
    }

    public String getUrl() {
        return url;
    }

    public String getContent() {
        return HtmlRenderer.builder().build().render(parsedResource);
    }
}


我不喜欢“智能构造器”,但到目前为止它还没有引起任何问题。关于如何改进它的想法(或者让它像现在这样离开的声音)是受欢迎的。

视图

我已经创建了一些简单的视图作为演示的目的,但这是一些我将不得不花费大量的时间稍后使应用程序看起来很好。

结束

就是这样!三个测试和三个类:控制器将大部分工作委托给阅读器,阅读器将资源转换为降价帖子,帖子使用库提取一些数据。如此简单,但它却像一个符咒。你可以看到它在工作here。所有源代码都是可用的here