时也势也,运也命也
叹:时运不济,命途多舛。值废时,冲牛马,煞榻下;忌努力,宜躺平。所行者皆废,所欲者皆不达。盖因时运不济,便纵有竭诚,难见成效。不若躺以听天命,平而顺自然。此乃:时也势也,运也命也,非人力所能移也。
寻新芝士,聊以消遣
既得闲暇,更无他事,自当寻滋以为乐,无他,聊以消遣耳。所行者何为?曰:择一(网)站者而爬之。余虽喜书,好读书,读烂书,却不肯付之一文,皆因吾之钱财,当别作他用,岂肯轻易与之?择之者谁?曰:书旗也。
内容概览
- 功能:Java爬虫
- 来源:书旗小说网免费章节
- 环境:JDK21
- 涉及:
- 流程设计
- 设计模式
- 任务编排
- 并发控制
- 多线程文件合并
- 分治思想
- 池化思想
小心谋划,谨慎布局
爬虫有法,所行者,当循其道。一曰「载」,二曰「择」,三曰「析」,四曰「译」,五曰「排」,六曰「录」,依此六法,事乃成矣。
流程概览
- Flow 调用流程图
- Flow调用时序图
- Merger(文件合并器)运行原理(IOForkJoinTask)
四韵既成,作文记之
絮絮叨叨,喋喋不休,啰啰嗦嗦者,止我也,非Java之过也。
成文,且观此处。「源」
预览:
/*
* 抽象通用节点
*/
@FunctionalInterface
public interface Task<T, R> extends Function<T, CompletableFuture<R>> {
@Override
default CompletableFuture<R> apply(T param) {
return execute(param);
}
CompletableFuture<R> execute(T param);
/**
* 高阶函数:利用函数式编程的函数组合特性,来组装两个任务
* 其中 {@link CompletableFuture#thenCompose} 为串行调用,不会涉及异步操作
* 需要开启异步时,应该在头结点使用 {@link CompletableFuture#thenApplyAsync}
*/
default <V> Task<T, V> then(Task<? super R, V> next) {
Assert.isTrue(next, Assert::isNotNull, () -> new NullPointerException("An unexamined life is not worth living. — Socrates"));
return t -> execute(t).thenCompose(next);
}
}
/**
* 抽象流程:组装多个 Task,形成一条任务链
*/
public interface Flow<T, R> {
// 任务头结点
Task<T, R> head();
// 启动此任务
default R start(T t) {
return head().execute(t).join();
}
/**
* 其中 CompletableFuture::completedFuture 等价于 t -> CompletableFuture.completedFuture(t)
* 对应 flow 的头结点: 对传入的类型包装成 CompletableFuture并返回
*/
static <T> Flow<T, T> identity() {
return () -> CompletableFuture::completedFuture;
}
/**
* 根据 章节列表flow 返回的条目,来生成对应数量的,下载章节流程
* 因为后续的下载章节流程对应的是一条章节,需要将章节列表处理成 N 条下载章节流程
*/
static <T> List<Flow<T, T>> startParallel(Integer size) {
return IntStream.range(0, size)
.mapToObj(unused -> Flow.<T>identity())
.toList();
}
// 每条flow 由多个 task#then 组装而成,而 flow 和 flow 之间的 then,实则也是用 头结点的 then 来组装
default <V> Flow<T, V> then(Flow<? super R, V> next) {
Assert.isTrue(next, Assert::isNotNull, () -> new NullPointerException("If I looked compared to others far, is because I stand on giant’s shoulder. — Newton"));
return () -> head().then(next.head());
}
/**
* 关于流程的组装,这里想稍稍多谈一点,其实一开始想用「模板方法模式」组装多个任务成一条抽象流程,用「迭代器」组装多条流程
* 但实际操作时,发现参数和返回值的不统一,不太可行,因为迭代需要提供统一的调用方式,强行统一的话,只能用更宽泛的类型来接受
* 并且在使用时使用 instanceof 判断强转,我所不欲也,要将上一个处理器的返回作为下一个处理器的输入,明显更符合流式编程、管道模式
* 用泛型来描述前后两者的关系还是比较容易的,故而选用了一种 a.then(b).then(c) 的模式来设计代码
*/
class Flows {
// 完整 下载bid 的流程组装
public static Flow<String, String> bidFlow() {
return () -> Reader.Readers.bidReader()
.then(Selector.Selectors.bidSelector())
.then(Parser.Parsers.bidParser());
}
// 完整 下载章节列表 的流程组装
public static Flow<String, List<Chapter.Chapter4Download>> chapterFlow() {
return () -> Reader.Readers.chapterReader()
.then(Selector.Selectors.chapterSelector())
.then(Parser.Parsers.chapterParser());
}
// 完整 下载章节内容 的流程组装[针对所有章节内容]
public static Flow<List<Chapter.Chapter4Download>, List<Chapter.Chapter4Merge>> contentListFlow() {
final var contentFlow = Flow.Flows.contentFlow();
return () ->
downloads -> {
var contentFlowStarts = Flow.<Chapter.Chapter4Download>startParallel(downloads.size());
final var parallelFlows = contentFlowStarts.stream()
//.limit(30) // 仅下载前 30章: 用于测试时,控制下载章节数量
.map(flow -> flow.then(contentFlow))
.toList();
var atoLong = new AtomicLong(0);
var sources = IntStream.range(0, parallelFlows.size())
.mapToObj(index -> parallelFlows.get(index).start(downloads.get(index)))
.sorted(Comparator.comparing(chapter4Merge ->
// 文件名的数字顺序
Integer.valueOf(chapter4Merge.filePath().getFileName().toString().transform(str -> str.substring(0, str.lastIndexOf(".")))))
)
.map(merge -> {
try {
// 这里设置每章的 skip 跳过字节数,因为用了 recode,final 类设计,无setter可用,只能我转我自己,多了 skip : atoLong.getAndAdd(size)
long size = Files.size(merge.filePath());
return new Chapter.Chapter4Merge(merge.orderId(), merge.folderPath(), merge.filePath(), merge.bookName(), atoLong.getAndAdd(size));
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList();
return CompletableFuture.completedFuture(sources);
};
}
// 部分 下载章节内容 的流程组装[针对一条章节内容]
public static Flow<Chapter.Chapter4Download, Chapter.Chapter4Merge> contentFlow() {
return () -> Reader.Readers.contentReader()
.then(Selector.Selectors.contentSelector())
.then(Parser.Parsers.contentParser())
.then(Decoder.Decoders.contentDecoder())
.then(Formatter.Formatters.contentFormatter())
.then(Writer.Writes.fileWriter());
}
// 完整 合并文件 的流程组装
public static Flow<List<Chapter.Chapter4Merge>, IOForkJoinTask.Result> mergeFlow() {
return Merger.Mergers::fileMerger;
}
}
}
public class FlowEngine implements AutoCloseable {
private static final Logger logger = LoggerFactory.getLogger(FlowEngine.class);
// io密集型任务线程池 :核心线程数 = cpu核心数 /(1-阻塞系数)
// public static final ExecutorService IO_TASK_EXECUTOR = new ThreadPoolExecutor(
// (int) (Runtime.getRuntime().availableProcessors() / (1 - 0.9)),
// (int) (Runtime.getRuntime().availableProcessors() / (1 - 0.9)) * 2,
// 10,
// TimeUnit.SECONDS,
// new ArrayBlockingQueue<>(2000),
// new ThreadPoolExecutor.AbortPolicy()
// );
// 既然用JDK21,为什么不试试虚拟线程
public static final ExecutorService IO_TASK_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
// cpu密集型任务线程池,核心线程数 = cpu核心数 + 1
@SuppressWarnings("unused")
public static final Executor CPU_TASK_EXECUTOR = new ForkJoinPool(Runtime.getRuntime().availableProcessors() + 1);
// 在下载章节内容时,最大允许并发数
public static final Integer MAX_ALLOWED = 3;
public static final Semaphore SEMAPHORE = new Semaphore(MAX_ALLOWED);
// 单例模式:静态实例对象
// 使用 volatile 修饰,防止指令重排导致的 NPE 问题
public static volatile FlowEngine defaultFlowEngine;
private FlowEngine() {
if (defaultFlowEngine != null)
throw new IllegalStateException("Don’t judge each day by the harvest you reap but by the seeds that you plant. — Robert Louis Stevenson");
}
// dcl 单例
public static FlowEngine getDefaultFlowEngine() {
if (defaultFlowEngine == null)
synchronized (FlowEngine.class) {
if (defaultFlowEngine == null) defaultFlowEngine = new FlowEngine();
}
return defaultFlowEngine;
}
// 组装串联流程
public void start(String bookName) {
logger.info("{} - 下载{}流程启动", Thread.currentThread().getName(), bookName);
var bidFlow = Flow.Flows.bidFlow();
var chapterFlow = Flow.Flows.chapterFlow();
var downloads = bidFlow.then(chapterFlow)
.start(bookName);
var contentListFlow = Flow.Flows.contentListFlow();
var sources = contentListFlow.start(downloads);
// 执行文件合并流程
var mergedFlow = Flow.Flows.mergeFlow();
var result = mergedFlow.start(sources);
logger.info("{} - 下载{}流程完成 - {}", Thread.currentThread().getName(), bookName, result);
}
public void end() {
FlowEngine.IO_TASK_EXECUTOR.shutdown();
logger.info("{} - 流程结束", Thread.currentThread().getName());
}
@Override
public void close() {
end();
}
}
/**
* 组件:发送请求,获取响应文本
*/
public interface Reader<T, R> extends Task<T, R> {
@Override
default CompletableFuture<R> execute(T param) {
return read(param);
}
CompletableFuture<R> read(T param);
// 发送请求,获取响应文本
static CompletableFuture<String> read0(String uri) {
try (var httpClient = HttpClient.newBuilder().build()) {
var request = HttpRequest.newBuilder()
.uri(URI.create(uri))
.build();
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body);
}
}
/**
* 工厂类: 使用静态工厂 && 策略
* 这里甚至还是"单例",使用 lambda 表达式创建对象,在不依赖外部状态的情况下,始终为同一个对象
* 所以,即使多次调用 bidReader(),返回的也是同一个对象,一旦涉及到外部状态,那就会重复生成对象了
* 后续:{@link Selector.Selectors} {@link Parser.Parsers} {@link Decoder.Decoders}
* {@link Formatter.Formatters} {@link Writer.Writes} 同理,不再重复注释
*/
class Readers {
private static final Logger logger = LoggerFactory.getLogger(Readers.class);
// 获取bid的http请求器
public static Reader<String, String> bidReader() {
// 获取BID的请求地址
final var bidUriFormatter = "https://www.shuqi.com/search?keyword=%s&page=1";
return bookName -> {
var bidUri = bidUriFormatter.formatted(bookName);
logger.info("{} - 执行获取bid操作 url => {}", Thread.currentThread().getName(), bidUri);
return CompletableFuture.completedFuture(bidUri)
.thenCompose(Reader::read0);
};
}
// 获取章节列表的http请求器
public static Reader<String, String> chapterReader() {
// 获取章节列表的请求地址
final var chapterUriFormatter = "https://www.shuqi.com/reader?bid=%s";
return bid -> {
var chapterUri = chapterUriFormatter.formatted(bid);
logger.info("{} - 执行获取章节列表操作 url => {}", Thread.currentThread().getName(), chapterUri);
return CompletableFuture.completedFuture(chapterUri)
.thenCompose(Reader::read0);
};
}
// 获取章节内容的http请求器
public static Reader<Chapter.Chapter4Download, String> contentReader() {
// 获取章节内容请求地址
var contentUriFormatter = "https://c13.shuqireader.com/pcapi/chapter/contentfree/%s";
return chapter4Download -> {
var contentUri = contentUriFormatter.formatted(chapter4Download.contUrlSuffix());
logger.info("{} - 执行获取章节内容操作 url => {}", Thread.currentThread().getName(), contentUri);
// 异步执行
return CompletableFuture.completedFuture(contentUri)
.thenApplyAsync(uri -> {
try {
// 别改!别改!别改!后果自负!!!
// 流控 -- start
// 控制最大并发数
FlowEngine.SEMAPHORE.acquire();
// 休眠1s
TimeUnit.SECONDS.sleep(1);
// 流控 -- end
return read0(uri);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
FlowEngine.SEMAPHORE.release();
}
}, FlowEngine.IO_TASK_EXECUTOR)
// 很low的传参方式,但后续执行下载时需要这些参数,只能由此往后传递
// 拼接后续流程所需参数:书名/章节序号.txt 作为章节文件名
// 取值时是逆序,后来居上
.thenApply(CompletableFuture::join)
.thenApply(jsonStr -> String.format("%s#%s", chapter4Download.chapterOrdid(), jsonStr))// [2]
.thenApply(jsonStr -> String.format("%s#%s", chapter4Download.chapterName(), jsonStr))// [1]
.thenApply(jsonStr -> String.format("%s#%s", chapter4Download.bookName(), jsonStr));// [0]
};
}
}
}
/**
* 组件:从响应文本中挑选所需的元素
*/
@FunctionalInterface
public interface Selector<T, R> extends Task<T, R> {
@Override
default CompletableFuture<R> execute(T doc) {
return select(doc);
}
CompletableFuture<R> select(T doc);
@SuppressWarnings("unused")
static <T> Selector<T, T> identity() {
return CompletableFuture::completedFuture;
}
class Selectors {
public static final Logger logger = LoggerFactory.getLogger(Selectors.class);
// bid元素选择器
public static Selector<String, String> bidSelector() {
// bid 所在的元素地址
// <span class="btn js-addShelf disable" data-bid="53258" data-clog="shelf-shelf$$bid=53258">+书架</span>
// 网站调整了 bid 元素位置
final var bidXpath = "/html/body/div[1]/div[3]/div/div[4]/div/span[2]";
return bidDoc -> {
logger.info("{} - 执行选择bid元素操作", Thread.currentThread().getName());
return CompletableFuture.completedFuture(
Jsoup.parse(bidDoc)
.selectXpath(bidXpath)
.getFirst()
.attr("data-bid")
);
};
}
// 章节列表元素选择器
public static Selector<String, String> chapterSelector() {
var chapterXpath = "/html/body/i[5]";
return chapterDoc -> {
logger.info("{} - 执行选择章节列表元素操作", Thread.currentThread().getName());
return CompletableFuture.completedFuture(
Jsoup.parse(chapterDoc)
.selectXpath(chapterXpath)
.text());
};
}
// 章节内容元素选择器
public static Selector<String, String> contentSelector() {
// 不方便加日志,弃用
// return Selector.identity();
// 章节内容直接为 json 字符串,无需额外选择器,走个流程
return doc -> {
logger.info("{} - 执行选择章节内容元素操作", Thread.currentThread().getName());
return CompletableFuture.completedFuture(doc);
};
}
}
}
/**
* 组件:从选择的元素中进一步解析想要的内容
*/
@FunctionalInterface
public interface Parser<T, R> extends Task<T, R> {
@Override
default CompletableFuture<R> execute(T source) {
return parse(source);
}
CompletableFuture<R> parse(T source);
@SuppressWarnings("unused")
static <V> Parser<V, V> identity() {
return CompletableFuture::completedFuture;
}
static <T> T jsonParser(String json, Class<T> clazz) {
try {
return new ObjectMapper().readValue(json, clazz);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
class Parsers {
private static final Logger logger = LoggerFactory.getLogger(Parsers.class);
// bid解析器
public static Parser<String, String> bidParser() {
// 不方便加日志,弃用
// return Parser.identity();
// bid 无需额外解析,只是过个流程
return source -> {
logger.info("{} - 执行解析bid内容操作", Thread.currentThread().getName());
return CompletableFuture.completedFuture(source);
};
}
// 章节列表解析器
public static Parser<String, List<Chapter.Chapter4Download>> chapterParser() {
// 从最内层的json对象上移除这些属性,因为后续用不上,如果不手动移除,则要求在转换的对象上有这些属性,否则转json会失败
var ignoreProperties = List.of("payStatus", "chapterPrice", "wordCount", "chapterUpdateTime",
"shortContUrlSuffix", "oriPrice", "shelf", "isBuy", "isFreeRead");
// 提取属性向后传递,构建最终对象,在最内层的json对象上添加此属性
var addProperties = List.of("bookName", "authorName");
return chapterSource -> {
logger.info("{} - 执行解析章节列表操作", Thread.currentThread().getName());
try {
var jsonNode = new ObjectMapper().readValue(chapterSource, JsonNode.class);
// 章节列表
var chapterList = jsonNode.get("chapterList");
// {chapterList:[{volumeList:[{chapterId,chapterName,contUrlSuffix}]}]}
return CompletableFuture.completedFuture(
IntStream.range(0, chapterList.size())
.mapToObj(chapterList::get)
.map(volumeOuter -> volumeOuter.get("volumeList"))
.flatMap(volumes -> IntStream.range(0, volumes.size())
.mapToObj(volumes::get)
.toList()
.stream())
.peek(chapter -> {
// 剔除忽略属性
ignoreProperties.forEach(property -> ((ObjectNode) chapter).remove(property));
// 添加外层属性
addProperties.forEach(property -> ((ObjectNode) chapter).putIfAbsent(property, jsonNode.get(property)));
})
.map(JsonNode::toString)
.map(chapterStr -> {
try {
return new ObjectMapper().readValue(chapterStr, Chapter.Chapter4Download.class);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList()
);
} catch (Exception e) {
throw new RuntimeException(e);
}
};
}
// 章节内容解析器
public static Parser<String, Chapter.Chapter4Save> contentParser() {
return contentSource -> {
logger.info("{} - 执行解析章节内容操作", Thread.currentThread().getName());
// contentReader -> contentSelector -> contentParse
// 处理拼接的参数,前面 contentReader 下载完成后拼接专递过来的
var strArr = contentSource.split("#");
var bookName = strArr[0];
var chapterName = strArr[1];
var chapterOrdid = strArr[2];
var jsonStr = strArr[3];
// json 转换为 章节内容对象[尚需解密]
var content = Parser.jsonParser(jsonStr, Content.class);
// 构建下一步[解密],需要的对象
return CompletableFuture.completedFuture(new Chapter.Chapter4Save(bookName, chapterName, chapterOrdid, content.ChapterContent()));
};
}
}
}
/**
* 组件:解密加密的章节内容
*/
@FunctionalInterface
public interface Decoder extends Task<Chapter.Chapter4Save, Chapter.Chapter4Save> {
@Override
default CompletableFuture<Chapter.Chapter4Save> execute(Chapter.Chapter4Save chapter) {
return decode(chapter);
}
CompletableFuture<Chapter.Chapter4Save> decode(Chapter.Chapter4Save content);
class Decoders {
private static final Logger logger = LoggerFactory.getLogger(Decoder.class);
public static Decoder contentDecoder() {
return chapter -> {
try {
logger.info("{} - 执行解密操作", Thread.currentThread().getName());
// 调用 js引擎池 解密章节内容
var scriptEngine = ScriptEnginePool.use();
var chapterContext = (String) ((Invocable) scriptEngine).invokeFunction("_decode", chapter.chapterContext());
// 归还 js引擎对象
ScriptEnginePool.release(scriptEngine);
return CompletableFuture.completedFuture(
new Chapter.Chapter4Save(
chapter.bookName(),
chapter.chapterName(),
chapter.chapterOrdid(),
chapterContext
)
);
} catch (Exception e) {
throw new RuntimeException(e);
}
};
}
}
}
/**
* 組件:内容格式化器,调整解密后的章节内容排版、操作包括:用\n 替换 <br/>, 去除收尾空白,拼接章节标题
*/
@FunctionalInterface
public interface Formatter extends Task<Chapter.Chapter4Save, Chapter.Chapter4Save> {
@Override
default CompletableFuture<Chapter.Chapter4Save> execute(Chapter.Chapter4Save chapter) {
return format(chapter);
}
CompletableFuture<Chapter.Chapter4Save> format(Chapter.Chapter4Save chapter);
class Formatters {
private static final Logger logger = LoggerFactory.getLogger(Formatters.class);
public static Formatter contentFormatter() {
return chapter -> {
logger.info("{} - 执行章节内容格式化操作", Thread.currentThread().getName());
var chapterContext = chapter.chapterContext()
// 替换换行符
.replaceAll("<br/>", "\n")
.lines()
// 去除首尾空白
.map(String::strip)
// 去除空白符后需要另行添加行尾添加换行
.collect(Collectors.joining("\n"))
// 拼接章节标题、行尾添加两个换行,方便后续文件合并时操作
.transform(str -> String.format("%s\n%s\n\n", chapter.chapterName(), str));
return CompletableFuture.completedFuture(new Chapter.Chapter4Save(chapter.bookName(), chapter.chapterName(), chapter.chapterOrdid(), chapterContext));
};
}
}
}
/**
* 组件:保存章节内容,将排版后的章节内容写入文件,每章为一个文件
*/
@FunctionalInterface
public interface Writer extends Task<Chapter.Chapter4Save, Chapter.Chapter4Merge> {
@Override
default CompletableFuture<Chapter.Chapter4Merge> execute(Chapter.Chapter4Save chapter) {
return write(chapter);
}
CompletableFuture<Chapter.Chapter4Merge> write(Chapter.Chapter4Save content);
class Writes {
private static final Logger logger = LoggerFactory.getLogger(Writes.class);
// 将章节内容打印在控制台,调试时用
@SuppressWarnings("unused")
public static Writer consoleWriter() {
return chapter -> {
logger.info("{} - 执行文件写入操作[控制台]", Thread.currentThread().getName());
var part = "-".repeat(15);
var separator = String.format("%s\t%s\t%s", part, chapter.chapterName(), part);
System.out.println(separator);
System.out.println(chapter.chapterContext());
return CompletableFuture.completedFuture((Chapter.Chapter4Merge) null);
};
}
// 将章节内容写入文件
public static Writer fileWriter() {
// 默认写入盘符: D盘
final var basePath = "D:";
return chapter -> {
logger.info("{} - 执行文件写入操作[文件系统]", Thread.currentThread().getName());
// 书名
var bookName = chapter.bookName();
// 章节序号为 1 - N 的连续自然数,实际章节标题中的章节序号与此不一定相符,因为网文作者有时请假或其他删减原因之类的,导致章节不连续,但ordid保证连续性
var fileName = chapter.chapterOrdid();
// 文件后缀
var fileType = "txt";
// 使用书名作为文件夹名
var folderPath = Paths.get(String.format("%s/%s", basePath, bookName));
// 章节文件名 e.g ==> D:/斗破苍穹/1.txt
var filePath = Paths.get(String.format("%s/%s/%s.%s", basePath, bookName, fileName, fileType));
try {
// 此处为并发环境, double check 以创建文件夹,实则是多余操作
// Files.createDirectories(folderPath) 行为:不存在则创建,存在时则不作任何操作,故为线程安全操作
// 多余的判断行为姑且就算做是用于提醒注意当前操作环境吧
if (Files.notExists(folderPath)) {
// 使用 String 作为锁对象时,intern 确保使用同一对象
synchronized (bookName.intern()) {
if (Files.notExists(folderPath)) Files.createDirectories(folderPath);
}
}
Files.writeString(filePath, chapter.chapterContext(), StandardCharsets.UTF_8);
return CompletableFuture.completedFuture(new Chapter.Chapter4Merge(Integer.valueOf(chapter.chapterOrdid()), folderPath, filePath, bookName));
} catch (Exception e) {
throw new RuntimeException(e);
}
};
}
}
}
/**
* 组件:执行文件合并操作
* 这里在文件成功合并后,之前零散的章节本可以直接删除,但基于安全考虑
* 删除文件是个不安全的行为,尤其还是删除别人的文件,所以呢,我这就不删了,文件合并后,自己手动删一下文件就行
*/
@FunctionalInterface
public interface Merger extends Task<List<Chapter.Chapter4Merge>, IOForkJoinTask.Result> {
@Override
default CompletableFuture<IOForkJoinTask.Result> execute(List<Chapter.Chapter4Merge> sources) {
return merge(sources);
}
CompletableFuture<IOForkJoinTask.Result> merge(List<Chapter.Chapter4Merge> sources);
class Mergers {
private static final Logger logger = LoggerFactory.getLogger(Mergers.class);
public static Merger fileMerger() {
return sources -> {
logger.info("{} - 执行文件合并操作 size => {}", Thread.currentThread().getName(), sources.size());
// 章节序号集合
var orderIds = sources.stream()
.map(Chapter.Chapter4Merge::orderId)
.toList();
// 获取开始索引和结束索引
var startIndex = orderIds.stream().min(Integer::compareTo).get();
var endIndex = orderIds.stream().max(Integer::compareTo).get();
// 计算整本书的字节数 也等于:最后一章的跳过字节数skip + 自身字节数
var totalLength = sources.stream()
.map(Chapter.Chapter4Merge::filePath)
.map(path -> {
try {
return Files.size(path);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.reduce(0L, Long::sum);
// capacity 表示,每个线程合并处理 100 章内容,此数值不宜设置过小,如拆分粒度过细,则任务过多会导致另类“死锁”问题,线程池的资源耗尽,全部线程都陷入阻塞等待。
var capacity = 100;
// 组装 IOForkJoinTask 所需任务数据
var partBook = new PartBook(sources, startIndex, endIndex, capacity, totalLength, FlowEngine.IO_TASK_EXECUTOR);
return CompletableFuture.completedFuture(partBook)
.thenApplyAsync(PartBook::compute, FlowEngine.IO_TASK_EXECUTOR);
};
}
}
}
/**
* 仿 ForkJoinPool 思路实现 IO密集型任务分治
* 为何使用它?
* Stream并行流,ForkJoinPool 同理,针对的场景是 CPU密集型任务(Stream并行流内部使用的就是ForkJoinPool)
* 文件写入属于IO密集型任务,明显不在此列,不是不能用,而是不合适,故而,文件IO、网络IO,我们应该使用自定义的IO型线程池
* 再将任务拆分算法封装、提交线程池代码按照 ForkJoinPool 思想封装,终有此工具类
*/
public interface IOForkJoinTask<T extends IOForkJoinTask<T>> {
default Logger logger() {
return LoggerFactory.getLogger(this.getClass());
}
// 线程池
ExecutorService executor();
// 起始索引
Integer startIndex();
// 结束索引
Integer endIndex();
// 可以处理的资源数量
Integer capacity();
// 是否需要拆分
default Boolean needsFork() {
return (endIndex() - startIndex()) > capacity();
}
// 要在线程内执行的任务的起始点
default Result compute() {
var logger = logger();
if (needsFork()) {
// push 子任务
CompletableFuture<Result>[] futures = fork();
// 阻塞当前任务
join(futures);
// 返回值用以计算任务成功数量
logger.info("{} - 等待返回 ...", Thread.currentThread().getName());
// 合并子任务返回结果
var result = Arrays.stream(futures)
.map(CompletableFuture::join)
.reduce(Result.ZERO, Result::reduce);
logger.info("{} - 返回结果:{}", Thread.currentThread().getName(), result);
return result;
} else {
return doCompute();
}
}
// 执行具体的任务操作由子类实现
Result doCompute();
// 任务拆分
@SuppressWarnings("unchecked")
default CompletableFuture<Result>[] fork() {
var tasks = doFork();
var left = tasks[0];
var right = tasks[1];
// 提交子任务至线程池异步执行
var leftFuture = CompletableFuture.completedFuture(left)
.thenApplyAsync(IOForkJoinTask::compute, executor());
// 提交子任务至线程池异步执行
var rightFuture = CompletableFuture.completedFuture(right)
.thenApplyAsync(IOForkJoinTask::compute, executor());
// 将 future 对象返回,给后面 join 方法调用
return new CompletableFuture[]{leftFuture, rightFuture};
}
// 具体拆分算法由子类实现
IOForkJoinTask<T>[] doFork();
// 当前任务阻塞等待子任务的返回结果
@SuppressWarnings("unchecked")
default void join(CompletableFuture<Result>... args) {
CompletableFuture.allOf(args)
.join();
}
/**
* 返回的结果封装
* @param successful 成功处理的资源数量
* @param byteSize 成功处理的字节数
*/
record Result(Integer successful, Long byteSize) {
public static final Result ZERO = new Result(0, 0L);
public static Result reduce(Result left, Result right) {
return new Result(left.successful + right.successful, left.byteSize + right.byteSize);
}
}
}
/**
* JS 引擎池:用缓存池思想,避免重复的创建和销毁资源
* 这里为何使用它呢?
* 因为调用js引擎执行解密操作,是处于多线程环境下的操作,在下载章节内容时,开启了异步,后续的一系列连续操作均为异步操作(thenCompose)
* 但js是单线程语言,多线程访问会报错,一开始我是使用一个 JS 引擎对象去加锁串行,但效率实在太慢
* 故在此初始化 缓存1000个 JS引擎对象来执行解密操作,各用各的,互不干扰
*/
public class ScriptEnginePool {
// 阻塞队列
private static final BlockingDeque<ScriptEngine> blockingDeque;
static {
// 缓存1000个JS引擎对象
blockingDeque = IntStream.rangeClosed(1, 1000)
.mapToObj(unused -> createScriptEngine())
.collect(Collectors.toCollection(LinkedBlockingDeque::new));
}
/**
* 生成JS引擎
*/
private static ScriptEngine createScriptEngine() {
var scriptEngineManager = new ScriptEngineManager();
var scriptEngine = scriptEngineManager.getEngineByName("JavaScript");
try (var inputStreamReader = new InputStreamReader(ScriptEnginePool.class.getResourceAsStream("/decode.js"))) {
scriptEngine.eval(inputStreamReader);
return scriptEngine;
} catch (IOException | ScriptException e) {
throw new RuntimeException(e);
}
}
/**
* 借用一个JS引擎对象
*/
public static ScriptEngine use() {
try {
return blockingDeque.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
/**
* 归还一个JS引擎对象
*/
public static void release(ScriptEngine scriptEngine) {
try {
blockingDeque.put(scriptEngine);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
作奸犯科,有司论刑
于文中紧要处(限流代码),不得妄动,后果自负。
但有作奸、犯科、妄动代码者,宜付有司论其刑,不宜偏私,使吾代之受过也(这个锅,我不背)。
补充说明,简明扼要
书旗网的实际流程
- 通过首页搜索书名获取bid
- 通过bid获取章节列表请求地址(每一章的请求地址以及加密参数)
- 通过每一章的请求地址获取每一章的内容