秘籍:使用函数计算珍藏你喜爱的文章

1. 背景

对于喜欢阅读博客的程序员来说,是不是常常堆满 tab 标签,密密麻麻的连标题都看不清?不舍得关掉,却又抽不出来时间阅读?又或者阅读过了,想收藏起来供日后查阅?

秘籍:使用函数计算珍藏你喜爱的文章

对这些文章来说,onenote、印象笔记等笔记类软件着实不是一个好的去处。因为每次做笔记时,看到这些密密麻麻的文章,再想到自己辛苦码的笔记就要淹没在这些文章里,往往作罢。

如果你也有这样的问题,那么现在,你有机会花费少许时间,就能解决这个问题了。现在就来看看我们怎么做到的吧。

2. 效果展示

首先,我们要实现的效果是通过函数计算定期将 rss 中的文章的内容以及文章页面所包含的 img、js、css 等资源文件一并下载到 oss 上,并提供预览功能。
实现的效果可以打开这个链接查看。

2.1 文章列表

秘籍:使用函数计算珍藏你喜爱的文章

2.2 文章展现效果

秘籍:使用函数计算珍藏你喜爱的文章

3. 技术方案

秘籍:使用函数计算珍藏你喜爱的文章

Time Trigger: 利用函数计算的 Time Trigger,定时触发函数的执行,对于喜欢早读的同学来说,将时间定在早上 5 点再合适不过了,cron 表达式为 0 0 5 * * *

对象存储 OSS:利用 OSS 可以保存下载的博客,并且通过 OSS 自带的静态网站托管模式 可以快速搭建一个静态网站,供用户预览博客。

表格存储 OTS:是一款 NoSQL 服务,可以用来存储 rss 订阅源,也可以用来存储已经处理过的博客记录,这样即使频繁调用,也不会重复下载。

函数计算:利用函数计算服务,我们可以轻松的完成 Serverless 架构应用的开发。在本例中,我们使用函数计算完成文章下载、上传等功能。

4. 环境配置

4.1 获取 AccessKey

要使用 fcli,就需要获取 AccessKey。AccessKey 可以在这里获取,如果不存在,则需要创建。

4.2 配置角色

角色是为了让函数计算服务有权限调用 OSS、OTS 等服务。

想要创建角色,就要进入控制台,依次选择角色管理、新建角色,就进入了角色创建的流程:

  1. 角色类型选择服务角色
    秘籍:使用函数计算珍藏你喜爱的文章
  2. 类型信息选择 FC 函数计算
    秘籍:使用函数计算珍藏你喜爱的文章
  3. 角色基本信息
    秘籍:使用函数计算珍藏你喜爱的文章
  4. 创建成功

角色创建好后,就要为该角色进行授权了。

在角色管理界面选择刚才创建成功的角色,然后依次选择角色授权策略、编辑授权策略,在弹出的页面中,选择我们需要的授权策略。比如我们这里需要读取写入 oss 和 ots ,因此需要添加 AliyunOSSFullAccess、AliyunOTSFullAccess。

秘籍:使用函数计算珍藏你喜爱的文章


进行到这一步,我们就有了一个叫做 fc 的角色。这里我们需要记录下角色详情中的 arn 备用。

秘籍:使用函数计算珍藏你喜爱的文章

4.2 maven 项目

4.2.1 创建 maven 项目

mvn archetype:generate -DgroupId=example -DartifactId=RssFcDemo -DarchetypeArtifactId=maven-archetype-quickstart -Dversion=1.0-SNAPSHOT -B

4.2.2 导入 maven 项目

要将项目导入到 IDEA,右击 pom.xml,选择 IDEA 打开即可。

4.2.3 将依赖一起打包到 jar

我们的项目会使用很多第三方的 jar,而这些 jar 在函数计算的 java 运行环境中不存在,所以我们在打包 jar 的时候,需要将第三方依赖一起打包到 jar 中。

配置的方法为在 pom.xml 中添加:

<build>
    <plugins>
        <!-- see https://maven.apache.org/plugins/maven-assembly-plugin/usage.html -->
        <plugin>
            <artifactId>maven-assembly-plugin</artifactId>
            <version>3.1.0</version>
            <configuration>
                <descriptorRefs>
                    <descriptorRef>jar-with-dependencies</descriptorRef>
                </descriptorRefs>
                <appendAssemblyId>false</appendAssemblyId> <!-- this is used for not append id to the jar name -->
            </configuration>
            <executions>
                <execution>
                    <id>make-assembly</id> <!-- this is used for inheritance merges -->
                    <phase>package</phase> <!-- bind to the packaging phase -->
                    <goals>
                        <goal>single</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <source>1.8</source>
                <target>1.8</target>
            </configuration>
        </plugin>
    </plugins>
</build>

配置完成后,使用 mvn package,就可以将 maven 依赖一起打包到 jar 中。

4.2.4 编写 hello world 代码

为了简单测试一下,这里我们先实现一个 hello world 的例子。

首先在 pom.xml 中添加 fc-java-core 的依赖。

<dependency>
    <groupId>com.aliyun.fc.runtime</groupId>
    <artifactId>fc-java-core</artifactId>
    <version>1.0.0</version>
</dependency>

fc-java-core 内,只包含了几个我们会用到的接口声明,其中最重要的就是 StreamRequestHandler 了。他定义了函数计算所需要的入口函数。打开我们刚才例子中的 App 类,将其修改为:

public class App implements StreamRequestHandler  {

    @Override
    public void handleRequest(InputStream input, OutputStream output, Context context) throws IOException {
        output.write("hello world!".getBytes());
    }
}

使用 mvn package,即可编译、打包项目。打包好的 jar 在 target 目录下。

接下来我们会通过 fcli 管理函数计算服务,并运行我们的代码。

4.3 fcli

4.3.1 配置 fcli

函数计算的管理可以通过界面来控制,也可以通过命令行工 fcli 来控制。这里我们使用命令行工具。具体的配置方法参见。access key 的获取参见 3.1 获取 AccessKey

配置完成后,输入 fcli shell,进入交互模式。

4.3.2 通过 fcli 创建服务

接下来,我们要创建服务。服务是函数计算资源管理的单位。服务可以配置权限、日志等。一个服务下可以创建很多函数,服务下的函数共享这些配置。

fcli 创建服务的命令为:

mks RssDemo -r acs:ram::myId:role/fc 

其中,我们的服务名叫做 RssDemo,-r 指的是服务对应的角色,这里填上我们前面得到的 arn 即可。

4.3.3 通过 fcli 创建函数

使用 cd RssDemo 进入到 RssDemo 服务下。

然后使用 mkf 命令创建一个函数:

mkf RssHandler -t java8 -h expamle.App::handleRequest -f ./target/RssFcDemo-1.0-SNAPSHOT.jar -m 512

我们创建的函数,名称为 RssHandler。其它参数的含义依次为:

-t 表示使用 java8 运行环境。
-h 表示入口函数为 expamle.App::handleRequest,也就是触发 RssHandler 这个函数时,会调用 example 包下的 App 类中的 handleRequest 方法。
-f 指定打包好的 jar 包的路径,这个是相对于执行 fcli shell 时的目录的。
-m 指的是函数执行时的内存大小,这里使用 512M 内存。

执行 mkf 除了会创建 RssHandler,还会将 -d 指定的目录下的内容上传到函数计算服务。

4.3.4 通过 fcli 运行函数

直接运行 invk RssHandler 即可得到 hello world 的输出。

4.3.5 通过 fcli 更新代码

如果我们修改了代码,比如,我们将

output.write("hello world!".getBytes());

修改为

output.write("hello world again!".getBytes());

要重新上传代码,只需要执行

upf RssHandler -f ./target/RssFcDemo-1.0-SNAPSHOT.jar

然后重新执行 invk RssHandler 即可。

至此,我们就顺利的完成了 hello world 的编写,接下来我们会一点点来实现我们的功能。

4.3.6 通过 fcli 配置 Time Trigger

首先在项目目录创建一个 timeTriggerConfig.yaml,内容为:

triggerConfig:
  payload: "awesome-fc" 
  cronExpression: "0 0 5 * * ?"
  enable: true

然后通过 fcli 创建触发器,触发器的名称为 timetrigger

mkt /fc/RssDemo/RssHandler/timetrigger -t timer -c timeTriggerConfig.yaml

这里需要注意的是,如果使用相对路径,在服务下或者函数下,mkt 后面跟的路径可能是不一样的,因此这里直接使用绝对路径,避免这种情况发生。

创建好,Time trigger 后,就会在指定的时间,触发函数了。

5. 功能实现

5.1 rss 服务类——RssService

创建一个类 RssService。这个类的主要功能为查询 ots 数据、解析 rss,因此我们需要添加 ots 和 rome 的依赖

<dependency>
    <groupId>com.aliyun.openservices</groupId>
    <artifactId>tablestore</artifactId>
    <version>4.3.1</version>
</dependency>
<dependency>
    <groupId>com.rometools</groupId>
    <artifactId>rome</artifactId>
    <version>1.9.0</version>
</dependency>

接下来实现具体的功能:

  1. 通过构造函数传入所需的参数

    public RssService(String endpoint, String accessKeyId, String accessKeySecret, String securityToken, String instanceName) {
        syncClient = new SyncClient(endpoint, accessKeyId, accessKeySecret, instanceName, securityToken);
        ensureTableExist();
    }
  2. 检查 ots 是否存在 rss_list 表,如果不存在,则创建

    private void ensureTableExist() {
        ListTableResponse response = syncClient.listTable();
        boolean exist = response.getTableNames().stream()
                .anyMatch(table -> table.equals(TABLE_NAME));
    
        if ( ! exist ) {
            TableMeta tableMeta = new TableMeta(TABLE_NAME);
    
            tableMeta.addPrimaryKeyColumn(new PrimaryKeySchema(PRIMARY_RSS, PrimaryKeyType.STRING));
    
            // 永不过期
            int timeToLive = -1;
    
            // 保存的最大版本数
            int maxVersions = 1;
    
            TableOptions tableOptions = new TableOptions(timeToLive, maxVersions);
            CreateTableRequestEx request = new CreateTableRequestEx(tableMeta, tableOptions);
    
            syncClient.createTable(request);
        }
    }
  3. 从 ots 上的 rss_list 表查询 Rss 订阅源列表

    public List<String> queryAllRss() {
        RangeIteratorParameter rangeIteratorParameter = new RangeIteratorParameter(TABLE_NAME);
    
        // 设置起始主键
        PrimaryKeyBuilder primaryKeyBuilder = PrimaryKeyBuilder.createPrimaryKeyBuilder()
                .addPrimaryKeyColumn(PRIMARY_RSS, PrimaryKeyValue.INF_MIN);
        rangeIteratorParameter.setInclusiveStartPrimaryKey(primaryKeyBuilder.build());
    
        // 设置结束主键
        primaryKeyBuilder = PrimaryKeyBuilder.createPrimaryKeyBuilder()
                .addPrimaryKeyColumn(PRIMARY_RSS, PrimaryKeyValue.INF_MAX);
        rangeIteratorParameter.setExclusiveEndPrimaryKey(primaryKeyBuilder.build());
    
        rangeIteratorParameter.setMaxVersions(1);
    
        Iterator<Row> iterator = syncClient.createRangeIterator(rangeIteratorParameter);
    
        Iterable<Row> iterable = () -> iterator;
    
        return StreamSupport.stream(iterable.spliterator(), false)
                .map(row -> row.getPrimaryKey().getPrimaryKeyColumn(PRIMARY_RSS).getValue().asString())
                .collect(Collectors.toList());
    }
  4. 解析 rss 订阅源,找出所包含的所有博客链接

    public List<String> parseRss(String rss) {
        try {
            SyndFeedInput input = new SyndFeedInput();
    
            SyndFeed feed = input.build(new XmlReader(new URL(rss)));
    
            return feed.getEntries().stream()
                    .map(e -> e.getLink())
                    .collect(Collectors.toList());
        } catch (Exception e) {
            e.printStackTrace();
            return Collections.emptyList();
        }
    }

    写完代码,我们还需要到表格存储控制台创建一个实例,比如我们这里叫做 rssdb,然后记录下 endpoint 就可以写代码测试了。这里不再阐述。

5.2 博客下载类——PageService

接下来我们来实现将博客下载到本地的 PageService。我们这里的思路主要是给定一个博客地址,首先获取该博客的 title,然后在本地新建一个名叫 title 的目录,将博客内容下载到该目录内。这里还需要处理博客内的静态资源文件,比如将 css、img、js 离线到本地,并将原博客中的所有对这些资源的引用都修改为相对路径。

具体的实现代码:

public class PageService {

    private File downloadHome;

    private FunctionComputeLogger logger;

    public PageService(File downloadHome, FunctionComputeLogger logger) {
        this.downloadHome = downloadHome;
        this.logger = logger;
    }

    public PageRecord downloadPage(String url) {
        try {
            if ( ! downloadHome.exists() ) {
                downloadHome.mkdirs();
            }

            Connection.Response response = Jsoup.connect(url)
                    .userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Safari/537.36")
                    .execute();

            final Document document = response.parse();

            String title = nomalLize(document.title());

            File pageHome = new File(downloadHome, title);
            pageHome.mkdir();

            // download all img to disk
            downloadResources(document, pageHome, "img", "img", "src");

            // download all css to disk
            downloadResources(document, pageHome, "css", "link[rel=stylesheet]", "href");

            // download all js to disk
            downloadResources(document, pageHome, "js", "script", "src");

            // write page to disk
            final byte[] body = document.toString().getBytes();
            Files.write(new File(pageHome, "index.html").toPath(), body);

            return new PageRecord(url, title);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private void downloadResources(Document document, File pageHome, String resourceName, String selector, String attr) {
        File resourceDir = new File(pageHome, resourceName);
        resourceDir.mkdir();

        // download all img disk
        Elements elements = document.select(selector);
        elements.stream().forEach(element -> {
            //make sure to get the absolute URL using abs: prefix
            String absSrc = element.attr("abs:" + attr);

            if ( ! isEmpty(absSrc) ) {
                int start = absSrc.lastIndexOf("/");
                if (start < 0) start = -1;

                int end = absSrc.lastIndexOf("?");
                if (end < 0 || end < start) end = absSrc.length();

                String name = absSrc.substring(start + 1, end);

                String relativePath = String.format("%s/%s", resourceName, name);

                try {
                    Files.write(new File(resourceDir, name).toPath(), Jsoup.connect(absSrc)
                            .userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Safari/537.36")
                            .ignoreContentType(true).execute().bodyAsBytes());
                } catch (IOException e) {
                    logger.error("error occur while downloading resource: " + absSrc + " and error is " + e.getMessage());
                }

                element.attr(attr, relativePath);
            }
        });

    }

    public String nomalLize(String title) {
        return title.replaceAll("/", "_");
    }

}

5.3 下载记录类——RecordService

该类主要负责功能有:

  1. 每次处理完博客时,使用该类将处理记录更新到 ots 上

    public void putRecord(PageRecord pageRecord) {
        PrimaryKeyBuilder primaryKeyBuilder = PrimaryKeyBuilder.createPrimaryKeyBuilder();
    
        primaryKeyBuilder.addPrimaryKeyColumn(PRIMARY_URL, PrimaryKeyValue.fromString(pageRecord.getUrl()));
        PrimaryKey primaryKey = primaryKeyBuilder.build();
    
        RowPutChange rowPutChange = new RowPutChange(TABLE_NAME, primaryKey);
    
        rowPutChange.addColumn(new Column(COLUMN_FILENAME, ColumnValue.fromString(pageRecord.getFilename())));
    
        syncClient.putRow(new PutRowRequest( rowPutChange));
    }
  2. 给定要下载的博客链接,查询并返回需要处理的博客

    public List<String> getNotExistRecords(List<String> urls) {
        if (urls == null || urls.size() == 0) return Collections.emptyList();
    
        MultiRowQueryCriteria multiRowQueryCriteria = new MultiRowQueryCriteria(TABLE_NAME);
    
        multiRowQueryCriteria.setMaxVersions(1);
    
        urls.stream().forEach(url -> {
            PrimaryKey primaryKey = PrimaryKeyBuilder.createPrimaryKeyBuilder()
                    .addPrimaryKeyColumn(PRIMARY_URL, PrimaryKeyValue.fromString(url)).build();
            multiRowQueryCriteria.addRow(primaryKey);
        });
    
        BatchGetRowRequest batchGetRowRequest = new BatchGetRowRequest();
    
        batchGetRowRequest.addMultiRowQueryCriteria(multiRowQueryCriteria);
    
        BatchGetRowResponse response = syncClient.batchGetRow(batchGetRowRequest);
    
        return response.getSucceedRows().stream().filter(row -> row.getRow() == null)
                .map(row -> urls.get(row.getIndex()))
                .collect(Collectors.toList());
    }

5.4 博客存储类————OssService

该类负责将下载的博客上传到 oss,主要完成的功能有:

  1. 如果 Bucket 不存在,则创建

    private void ensureBucketExist() {
        if ( ! ossClient.doesBucketExist(bucketName) ) {
            // Create a new OSS bucket
            logger.info("Creating bucket " + bucketName + "\n");
            ossClient.createBucket(bucketName);
            CreateBucketRequest createBucketRequest= new CreateBucketRequest(bucketName);
            createBucketRequest.setCannedACL(CannedAccessControlList.PublicRead);
            ossClient.createBucket(createBucketRequest);
        }
    }
  2. 将本地下载的博客上传到 oss

    public void uploadFiles(Path path) throws IOException {
        logger.info("begin uploading static resources...");
    
        // upload files recursively
        try (Stream<Path> stream = Files.walk(path)) {
            stream.map(Path::toFile)
                    .filter(File::isFile)
                    .forEach(f -> {
                        Path relative = path.relativize(f.toPath());
    
                        ossClient.putObject(bucketName, relative.toString(), f);
                        logger.info(String.format("uploading file %s success", f.getAbsolutePath()));
                    });
        }
    
        logger.info("uploading static resources end...");
    }

存储到 oss 后,还需要到 oss 上配置默认首页 index.html。然后我们用代码生成 index.html 内容,使得用户可以查看已经上传的博客内容。

5.5 index.html 渲染类————TemplateService

这里我们使用 thymeleaf 进行渲染,因此需要添加依赖:

<dependency>
    <groupId>org.thymeleaf</groupId>
    <artifactId>thymeleaf</artifactId>
    <version>3.0.9.RELEASE</version>
</dependency>

渲染代码为:

public class TemplateService {

    private static TemplateEngine templateEngine;

    static {
        StringTemplateResolver resolver = new StringTemplateResolver();
        resolver.setTemplateMode(TemplateMode.HTML);

        templateEngine = new TemplateEngine();
        templateEngine.setTemplateResolver(resolver);
    }

    public String render(String template, final Map<String, Object> variables) {
        Context context = new Context();
        context.setVariables(variables);

        return templateEngine.process(template, context);
    }
}

这里我们使用一个简单的 index.html 模板:

private final static String INDEX_HTML_TEMPLATE = "\n" +
        "<!DOCTYPE html>\n" +
        "<html>\n" +
        "<head>\n" +
        "  <meta charset=\"utf-8\">\n" +
        "  <title>RssDemo</title>\n" +
        "</head>\n" +
        "<body>\n" +
        "<div th:each=\"filename, iterStat : ${filenames}\">\n" +
        "  <span>\n" +
        "    <a th:href=\"'/' + ${filename} + '/index.html'\" th:text=\"${filename}\"></a>\n" +
        "  </span>\n" +
        "</div>\n" +
        "</body>\n" +
        "</html>\n";

5.6 入口函数

所有的功能都已经实现,接下来就要编写入口函数了。

public class RssFC implements StreamRequestHandler {

    public final static String OSS_ENDPOINT = "https://oss-cn-hangzhou.aliyuncs.com";
    public final static String OSS_BUCKET_NAME = "rss-fc";

    public final static String OTS_ENDPOINT = "https://rssdb.cn-hangzhou.ots.aliyuncs.com";
    public final static String OTS_INSTANCE_NAME = "rssdb";

    private final static File RESOURCE_ROOT = new File("/tmp/pages");

    private final static String INDEX_HTML_TEMPLATE = ""; // 省略

    public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context) throws IOException {

        if ( ! RESOURCE_ROOT.exists() ) {
            RESOURCE_ROOT.mkdirs();
        }

        String accessKeyId = context.getExecutionCredentials().getAccessKeyId();
        String accessKeysecret = context.getExecutionCredentials().getAccessKeySecret();
        String securityToken = context.getExecutionCredentials().getSecurityToken();

        FunctionComputeLogger logger = context.getLogger();

        try (OssService ossService = new OssService(OSS_ENDPOINT, OSS_BUCKET_NAME, accessKeyId, accessKeysecret, securityToken, logger)) {
            RecordService recordService = new RecordService(OTS_ENDPOINT, accessKeyId, accessKeysecret, securityToken, OTS_INSTANCE_NAME);
            PageService pageService = new PageService(RESOURCE_ROOT, logger);
            RssService rssService = new RssService(OTS_ENDPOINT, accessKeyId, accessKeysecret, securityToken, OTS_INSTANCE_NAME);
            TemplateService templateService = new TemplateService();

            logger.info("query all links from ots...");

            rssService.queryAllRss().stream().forEach(rss -> {
                try {
                    List<String> pages = rssService.parseRss(rss);

                    logger.info("find newly links...");

                    List<String> notExistUrls = recordService.getNotExistRecords(pages);

                    logger.info("download page to disk...");

                    List<PageRecord> pageRecords = notExistUrls.stream()
                            .map(url -> pageService.downloadPage(url))
                            .collect(Collectors.toList());

                    logger.info("update records to ots");

                    pageRecords.stream()
                            .forEach(pageRecord -> recordService.putRecord(pageRecord));

                    logger.info("render index.html");

                    List<String> filenames = recordService.queryAllRecords().stream()
                            .map(PageRecord::getFilename)
                            .collect(Collectors.toList());

                    Map<String, Object> variables = new HashMap<>();
                    variables.put("filenames", filenames);

                    String renderedContent = templateService.render(INDEX_HTML_TEMPLATE, variables);
                    Files.write(new File(RESOURCE_ROOT + "/index.html").toPath(), renderedContent.getBytes());

                    // upload page to oss
                    logger.info("upload pages to oss");
                    ossService.uploadFiles(RESOURCE_ROOT.toPath());

                    logger.info("clean disk cache");
                    FileUtils.cleanDirectory(RESOURCE_ROOT);
                } catch (IOException e) {
                    logger.error("error occur: " + e.getMessage());
                }
            });


        }

        outputStream.write("finish...".getBytes());
    }
}

进行到这一步,就大功告成了。

6. 示例代码

https://github.com/awesome-fc/RssFcDemo

7. 总结

在开发过程中,推荐按照功能点,一个功能一个功能的开发,每开发完一个功能,编写相应的测试(请参照本文提供的示例代码)。

另外,还有很多很多的功能可以添加进来,比如可以通过 API 网关实现 RSS 列表的增删改查、保存指定博客,再结合 workflow 工具,轻松完成 installpaper、pocket 的功能,还可以通过结合 OpenSearch 提供搜索的功能。希望本文能起到抛砖引玉的效果。

上一篇:学习也要有策略


下一篇:Entity Fram“.NET研究”ework 4.1 Code First 学习之路(二)