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 等服务。
想要创建角色,就要进入控制台,依次选择角色管理、新建角色,就进入了角色创建的流程:
- 角色类型选择服务角色
- 类型信息选择 FC 函数计算
- 角色基本信息
- 创建成功
角色创建好后,就要为该角色进行授权了。
在角色管理界面选择刚才创建成功的角色,然后依次选择角色授权策略、编辑授权策略,在弹出的页面中,选择我们需要的授权策略。比如我们这里需要读取写入 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>
接下来实现具体的功能:
-
通过构造函数传入所需的参数
public RssService(String endpoint, String accessKeyId, String accessKeySecret, String securityToken, String instanceName) { syncClient = new SyncClient(endpoint, accessKeyId, accessKeySecret, instanceName, securityToken); ensureTableExist(); }
-
检查 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); } }
-
从 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()); }
-
解析 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
该类主要负责功能有:
-
每次处理完博客时,使用该类将处理记录更新到 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)); }
-
给定要下载的博客链接,查询并返回需要处理的博客
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,主要完成的功能有:
-
如果 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); } }
-
将本地下载的博客上传到 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 提供搜索的功能。希望本文能起到抛砖引玉的效果。