「剑走偏锋 · Java 爬虫设计与实践」

时也势也,运也命也

叹:时运不济,命途多舛。值废时,冲牛马,煞榻下;忌努力,宜躺平。所行者皆废,所欲者皆不达。盖因时运不济,便纵有竭诚,难见成效。不若躺以听天命,平而顺自然。此乃:时也势也,运也命也,非人力所能移也。


寻新芝士,聊以消遣

既得闲暇,更无他事,自当寻滋以为乐,无他,聊以消遣耳。所行者何为?曰:择一(网)站者而爬之。余虽喜书,好读书,读烂书,却不肯付之一文,皆因吾之钱财,当别作他用,岂肯轻易与之?择之者谁?曰:书旗也。

内容概览
  • 功能:Java爬虫
  • 来源:书旗小说网免费章节
  • 环境:JDK21
  • 涉及:
    1. 流程设计
    2. 设计模式
    3. 任务编排
    4. 并发控制
    5. 多线程文件合并
    6. 分治思想
    7. 池化思想

小心谋划,谨慎布局

爬虫有法,所行者,当循其道。一曰「载」,二曰「择」,三曰「析」,四曰「译」,五曰「排」,六曰「录」,依此六法,事乃成矣。

流程概览
  • Flow 调用流程图
Reader:执行请求下载
Selector:执行元素选择
Parser:执行文本解析
Decoder:执行文本解密
Formatter:执行文本格式化
Writer:执行文件写入
  • Flow调用时序图
Flow Reader Selector Parser Decoder Formatter Writer read() 返回响应文本 select() 返回选择的页面元素 parse() 返回解析后的文本 decode() 返回解密后的文本 format() 返回格式化后的文本 write() 返回写入完成的文件列表 Flow Reader Selector Parser Decoder Formatter Writer
  • Merger(文件合并器)运行原理(IOForkJoinTask)
提交异步任务
执行判断
计算任务结果直接返回
提交子任务,并阻塞等待子任务返回
提交子任务,并阻塞等待子任务返回
合并子任务结果
开始
task#compute
task#needsFork
task#fork
task#doCompute
task#join
执行任务拆分:task1
执行任务拆分:task2
结束

四韵既成,作文记之

絮絮叨叨,喋喋不休,啰啰嗦嗦者,止我也,非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);
        }
    }
}

作奸犯科,有司论刑

于文中紧要处(限流代码),不得妄动,后果自负。
但有作奸、犯科、妄动代码者,宜付有司论其刑,不宜偏私,使吾代之受过也(这个锅,我不背)。


补充说明,简明扼要

书旗网的实际流程
  1. 通过首页搜索书名获取bid
  2. 通过bid获取章节列表请求地址(每一章的请求地址以及加密参数)
  3. 通过每一章的请求地址获取每一章的内容
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值