From 33974590e71a15e3e97409973e11a2be78de9edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E9=A6=A8=E6=9C=88?= <615155262@qq.com> Date: Sun, 31 May 2026 21:13:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=90=E4=BA=A4=E6=89=80=E6=9C=89?= =?UTF-8?q?=E5=91=A8=E4=BD=9C=E4=B8=9A=EF=BC=88w1-w11=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- w10/AnalyzeCommand.java | 118 +++ w10/ArticleRepository.java | 40 + w10/CrawlStrategy.java | 13 + w10/CrawlerController.java | 49 ++ w10/DefaultStrategy.java | 40 + w10/RegexStrategy.java | 45 ++ w10/StrategyArchitectureAuditReport.java | 85 ++ w10/StrategyFactory.java | 50 ++ w10/java-cli/.gitignore | 4 + w10/java-cli/W10 PPT.md | 492 ++++++++++++ w10/java-cli/pom.xml | 52 ++ .../java/com/example/datacollect/Main.java | 21 + .../example/datacollect/command/Command.java | 8 + .../datacollect/command/CrawlCommand.java | 50 ++ .../datacollect/command/ExitCommand.java | 23 + .../datacollect/command/HelpCommand.java | 22 + .../datacollect/command/ListCommand.java | 22 + .../example/datacollect/model/Article.java | 45 ++ .../datacollect/strategy/BlogStrategy.java | 25 + .../datacollect/strategy/HnuNewsStrategy.java | 49 ++ .../datacollect/strategy/NewsStrategy.java | 25 + .../example/datacollect/view/ConsoleView.java | 42 + .../target/W9工程架构 - 教案v3.md | 758 ++++++++++++++++++ .../target/maven-archiver/pom.properties | 5 + .../compile/default-compile/createdFiles.lst | 0 .../compile/default-compile/inputFiles.lst | 15 + w10/java-cli/target/w9-ppt.md | 530 ++++++++++++ ...设计模式:灵活性与可扩展性.md | 705 ++++++++++++++++ w10/思考题.docx | Bin 0 -> 11382 bytes w11/.vscode/settings.json | 3 + w11/AI架构审计问答题与思考题.docx | Bin 0 -> 12251 bytes w11/w11/java-cli/.gitignore | 4 + w11/w11/java-cli/W10 PPT.md | 492 ++++++++++++ w11/w11/java-cli/pom.xml | 62 ++ .../java/com/example/datacollect/Main.java | 21 + .../datacollect/RetryUtilsExample.java | 129 +++ .../circuitbreaker/CircuitBreaker.java | 184 +++++ .../example/datacollect/command/Command.java | 8 + .../datacollect/command/CrawlCommand.java | 61 ++ .../datacollect/command/ExitCommand.java | 23 + .../datacollect/command/HelpCommand.java | 22 + .../datacollect/command/ListCommand.java | 22 + .../exception/UrlFormatException.java | 43 + .../example/datacollect/model/Article.java | 45 ++ .../datacollect/strategy/BlogStrategy.java | 25 + .../datacollect/strategy/HnuNewsStrategy.java | 49 ++ .../datacollect/strategy/NewsStrategy.java | 25 + .../example/datacollect/util/RetryUtils.java | 162 ++++ .../datacollect/util/UrlValidator.java | 49 ++ .../example/datacollect/view/ConsoleView.java | 42 + .../java-cli/src/main/resources/logback.xml | 63 ++ .../target/W9工程架构 - 教案v3.md | 758 ++++++++++++++++++ w11/w11/java-cli/target/classes/logback.xml | 63 ++ .../target/maven-archiver/pom.properties | 5 + .../compile/default-compile/createdFiles.lst | 0 .../compile/default-compile/inputFiles.lst | 16 + w11/w11/java-cli/target/w9-ppt.md | 530 ++++++++++++ ...设计模式:灵活性与可扩展性.md | 705 ++++++++++++++++ w3/BaseCrawler.java | 145 ++++ w3/IntangibleHeritageCrawler.class | Bin 0 -> 5586 bytes w3/IntangibleHeritageCrawler.java | 105 +++ w3/intangible_heritage.csv | 248 ++++++ w5/Circle.java | 28 + w5/Computer.java | 19 + w5/Keyboard.java | 15 + w5/Mouse.java | 15 + w5/Person.java | 56 ++ w5/Rectangle.java | 34 + w5/Shape.java | 17 + w5/ShapeCalculatorDemo.java | 57 ++ w5/ShapeUtil.java | 35 + w5/Student.java | 59 ++ w5/StudentManagementSystem.java | 38 + w5/Teacher.java | 59 ++ w5/USB.java | 15 + w6/Animal.java | 5 + w6/Cat.java | 8 + w6/Dog.java | 14 + w6/TestAnimal.java | 21 + w7/ScoreCalculator.java | 41 + w7/Test.java | 5 + w7/scores.txt | 9 + w8/AI协同学习和思考题.docx | Bin 0 -> 13579 bytes w8/GenericHomework.java | 171 ++++ w9/ArchitectureAuditReport.java | 113 +++ w9/Article.java | 104 +++ w9/CommandAlias.java | 129 +++ w9/HistoryCommand.java | 96 +++ w9/UIConstants.java | 81 ++ w9/UrlValidator.java | 134 ++++ w9/java-cli/.gitignore | 4 + w9/java-cli/README.md | 17 + w9/java-cli/pom.xml | 45 ++ .../java/com/example/datacollect/Main.java | 21 + .../SharedReferenceRisksSummary.java | 69 ++ .../example/datacollect/command/Command.java | 9 + .../datacollect/command/CrawlCommand.java | 27 + .../datacollect/command/ExitCommand.java | 24 + .../datacollect/command/HelpCommand.java | 23 + .../datacollect/command/ListCommand.java | 23 + .../controller/CrawlerController.java | 47 ++ .../example/datacollect/view/ConsoleView.java | 42 + .../target/W9工程架构 - 教案v3.md | 758 ++++++++++++++++++ .../target/maven-archiver/pom.properties | 5 + .../compile/default-compile/createdFiles.lst | 0 w9/java-cli/target/w9-ppt.md | 530 ++++++++++++ w9/思考题.docx | Bin 0 -> 10703 bytes 107 files changed, 10464 insertions(+) create mode 100644 w10/AnalyzeCommand.java create mode 100644 w10/ArticleRepository.java create mode 100644 w10/CrawlStrategy.java create mode 100644 w10/CrawlerController.java create mode 100644 w10/DefaultStrategy.java create mode 100644 w10/RegexStrategy.java create mode 100644 w10/StrategyArchitectureAuditReport.java create mode 100644 w10/StrategyFactory.java create mode 100644 w10/java-cli/.gitignore create mode 100644 w10/java-cli/W10 PPT.md create mode 100644 w10/java-cli/pom.xml create mode 100644 w10/java-cli/src/main/java/com/example/datacollect/Main.java create mode 100644 w10/java-cli/src/main/java/com/example/datacollect/command/Command.java create mode 100644 w10/java-cli/src/main/java/com/example/datacollect/command/CrawlCommand.java create mode 100644 w10/java-cli/src/main/java/com/example/datacollect/command/ExitCommand.java create mode 100644 w10/java-cli/src/main/java/com/example/datacollect/command/HelpCommand.java create mode 100644 w10/java-cli/src/main/java/com/example/datacollect/command/ListCommand.java create mode 100644 w10/java-cli/src/main/java/com/example/datacollect/model/Article.java create mode 100644 w10/java-cli/src/main/java/com/example/datacollect/strategy/BlogStrategy.java create mode 100644 w10/java-cli/src/main/java/com/example/datacollect/strategy/HnuNewsStrategy.java create mode 100644 w10/java-cli/src/main/java/com/example/datacollect/strategy/NewsStrategy.java create mode 100644 w10/java-cli/src/main/java/com/example/datacollect/view/ConsoleView.java create mode 100644 w10/java-cli/target/W9工程架构 - 教案v3.md create mode 100644 w10/java-cli/target/maven-archiver/pom.properties create mode 100644 w10/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst create mode 100644 w10/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst create mode 100644 w10/java-cli/target/w9-ppt.md create mode 100644 w10/java-cli/第10周——设计模式:灵活性与可扩展性.md create mode 100644 w10/思考题.docx create mode 100644 w11/.vscode/settings.json create mode 100644 w11/AI架构审计问答题与思考题.docx create mode 100644 w11/w11/java-cli/.gitignore create mode 100644 w11/w11/java-cli/W10 PPT.md create mode 100644 w11/w11/java-cli/pom.xml create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/Main.java create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/RetryUtilsExample.java create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/circuitbreaker/CircuitBreaker.java create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/command/Command.java create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/command/CrawlCommand.java create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/command/ExitCommand.java create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/command/HelpCommand.java create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/command/ListCommand.java create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/exception/UrlFormatException.java create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/model/Article.java create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/strategy/BlogStrategy.java create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/strategy/HnuNewsStrategy.java create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/strategy/NewsStrategy.java create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/util/RetryUtils.java create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/util/UrlValidator.java create mode 100644 w11/w11/java-cli/src/main/java/com/example/datacollect/view/ConsoleView.java create mode 100644 w11/w11/java-cli/src/main/resources/logback.xml create mode 100644 w11/w11/java-cli/target/W9工程架构 - 教案v3.md create mode 100644 w11/w11/java-cli/target/classes/logback.xml create mode 100644 w11/w11/java-cli/target/maven-archiver/pom.properties create mode 100644 w11/w11/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst create mode 100644 w11/w11/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst create mode 100644 w11/w11/java-cli/target/w9-ppt.md create mode 100644 w11/w11/java-cli/第10周——设计模式:灵活性与可扩展性.md create mode 100644 w3/BaseCrawler.java create mode 100644 w3/IntangibleHeritageCrawler.class create mode 100644 w3/IntangibleHeritageCrawler.java create mode 100644 w3/intangible_heritage.csv create mode 100644 w5/Circle.java create mode 100644 w5/Computer.java create mode 100644 w5/Keyboard.java create mode 100644 w5/Mouse.java create mode 100644 w5/Person.java create mode 100644 w5/Rectangle.java create mode 100644 w5/Shape.java create mode 100644 w5/ShapeCalculatorDemo.java create mode 100644 w5/ShapeUtil.java create mode 100644 w5/Student.java create mode 100644 w5/StudentManagementSystem.java create mode 100644 w5/Teacher.java create mode 100644 w5/USB.java create mode 100644 w6/Animal.java create mode 100644 w6/Cat.java create mode 100644 w6/Dog.java create mode 100644 w6/TestAnimal.java create mode 100644 w7/ScoreCalculator.java create mode 100644 w7/Test.java create mode 100644 w7/scores.txt create mode 100644 w8/AI协同学习和思考题.docx create mode 100644 w8/GenericHomework.java create mode 100644 w9/ArchitectureAuditReport.java create mode 100644 w9/Article.java create mode 100644 w9/CommandAlias.java create mode 100644 w9/HistoryCommand.java create mode 100644 w9/UIConstants.java create mode 100644 w9/UrlValidator.java create mode 100644 w9/java-cli/.gitignore create mode 100644 w9/java-cli/README.md create mode 100644 w9/java-cli/pom.xml create mode 100644 w9/java-cli/src/main/java/com/example/datacollect/Main.java create mode 100644 w9/java-cli/src/main/java/com/example/datacollect/SharedReferenceRisksSummary.java create mode 100644 w9/java-cli/src/main/java/com/example/datacollect/command/Command.java create mode 100644 w9/java-cli/src/main/java/com/example/datacollect/command/CrawlCommand.java create mode 100644 w9/java-cli/src/main/java/com/example/datacollect/command/ExitCommand.java create mode 100644 w9/java-cli/src/main/java/com/example/datacollect/command/HelpCommand.java create mode 100644 w9/java-cli/src/main/java/com/example/datacollect/command/ListCommand.java create mode 100644 w9/java-cli/src/main/java/com/example/datacollect/controller/CrawlerController.java create mode 100644 w9/java-cli/src/main/java/com/example/datacollect/view/ConsoleView.java create mode 100644 w9/java-cli/target/W9工程架构 - 教案v3.md create mode 100644 w9/java-cli/target/maven-archiver/pom.properties create mode 100644 w9/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst create mode 100644 w9/java-cli/target/w9-ppt.md create mode 100644 w9/思考题.docx diff --git a/w10/AnalyzeCommand.java b/w10/AnalyzeCommand.java new file mode 100644 index 0000000..bf0a931 --- /dev/null +++ b/w10/AnalyzeCommand.java @@ -0,0 +1,118 @@ +package com.example.datacollect.command; + +import com.example.datacollect.model.Article; +import com.example.datacollect.repository.ArticleRepository; +import com.example.datacollect.strategy.CrawlStrategy; +import com.example.datacollect.strategy.StrategyFactory; +import com.example.datacollect.view.ConsoleView; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class AnalyzeCommand implements Command { + private final ConsoleView view; + private final StrategyFactory strategyFactory; + + public AnalyzeCommand(ConsoleView view, StrategyFactory strategyFactory) { + this.view = view; + this.strategyFactory = strategyFactory; + } + + @Override + public String getName() { + return "analyze"; + } + + @Override + public String getDescription() { + return "analyze - 分析URL页面内容,输出统计信息(不保存)"; + } + + @Override + public void execute(String[] args, ArticleRepository repository) { + if (args.length < 2) { + view.printError("Usage: analyze "); + return; + } + String url = args[1]; + + CrawlStrategy strategy = strategyFactory.getStrategy(url); + if (strategy == null) { + view.printError("No strategy found for: " + url); + return; + } + + try { + view.printInfo("Analyzing: " + url); + Document doc = Jsoup.connect(url).get(); + List
articles = strategy.parse(url, doc); + + printStatistics(articles, url); + } catch (Exception e) { + view.printError("Failed to analyze: " + e.getMessage()); + } + } + + private void printStatistics(List
articles, String url) { + view.printSuccess("=== 分析报告 ==="); + view.printInfo("解析策略: " + strategyFactory.getStrategy(url).getClass().getSimpleName()); + view.printInfo("URL: " + url); + view.printInfo("文章数量: " + articles.size()); + + if (articles.isEmpty()) { + view.printInfo("未解析到任何文章"); + return; + } + + int emptyTitles = 0; + int minTitleLength = Integer.MAX_VALUE; + int maxTitleLength = 0; + int totalTitleLength = 0; + + for (Article article : articles) { + String title = article.getTitle(); + if (title == null || title.trim().isEmpty()) { + emptyTitles++; + } else { + int len = title.length(); + minTitleLength = Math.min(minTitleLength, len); + maxTitleLength = Math.max(maxTitleLength, len); + totalTitleLength += len; + } + } + + view.printInfo("--- 标题统计 ---"); + if (emptyTitles > 0) { + view.printInfo("空标题数量: " + emptyTitles); + } + view.printInfo("最短标题长度: " + (minTitleLength == Integer.MAX_VALUE ? 0 : minTitleLength)); + view.printInfo("最长标题长度: " + maxTitleLength); + view.printInfo("平均标题长度: " + String.format("%.1f", (double) totalTitleLength / (articles.size() - emptyTitles))); + + String domain = extractDomain(url); + Map domainDistribution = articles.stream() + .map(a -> extractDomain(a.getUrl())) + .collect(Collectors.groupingBy(d -> d, Collectors.counting())); + + view.printInfo("--- 来源域名分布 ---"); + for (Map.Entry entry : domainDistribution.entrySet()) { + view.printInfo(" " + entry.getKey() + ": " + entry.getValue() + " 篇"); + } + } + + private String extractDomain(String url) { + try { + int start = url.indexOf("://"); + if (start == -1) return "unknown"; + int end = url.indexOf("/", start + 3); + if (end == -1) return url.substring(start + 3); + return url.substring(start + 3, end); + } catch (Exception e) { + return "unknown"; + } + } +} \ No newline at end of file diff --git a/w10/ArticleRepository.java b/w10/ArticleRepository.java new file mode 100644 index 0000000..2fc672a --- /dev/null +++ b/w10/ArticleRepository.java @@ -0,0 +1,40 @@ +package com.example.datacollect.repository; + +import com.example.datacollect.model.Article; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class ArticleRepository { + private final List
articles = new ArrayList<>(); + + public void add(Article article) { + if (article == null) { + throw new IllegalArgumentException("Article cannot be null"); + } + articles.add(article); + } + + public void addAll(List
articles) { + if (articles == null) { + throw new IllegalArgumentException("Article list cannot be null"); + } + for (Article article : articles) { + if (article != null) { + this.articles.add(article); + } + } + } + + public List
getAll() { + return Collections.unmodifiableList(articles); + } + + public int size() { + return articles.size(); + } + + public void clear() { + articles.clear(); + } +} diff --git a/w10/CrawlStrategy.java b/w10/CrawlStrategy.java new file mode 100644 index 0000000..5c5dc72 --- /dev/null +++ b/w10/CrawlStrategy.java @@ -0,0 +1,13 @@ +package com.example.datacollect.strategy; + +import com.example.datacollect.model.Article; +import org.jsoup.nodes.Document; +import java.util.List; + +public interface CrawlStrategy { + List
parse(String url, Document doc); + boolean supports(String url); + default int getPriority() { + return 0; + } +} diff --git a/w10/CrawlerController.java b/w10/CrawlerController.java new file mode 100644 index 0000000..a043ac4 --- /dev/null +++ b/w10/CrawlerController.java @@ -0,0 +1,49 @@ +package com.example.datacollect.controller; + +import com.example.datacollect.command.AnalyzeCommand; +import com.example.datacollect.command.Command; +import com.example.datacollect.command.CrawlCommand; +import com.example.datacollect.command.ExitCommand; +import com.example.datacollect.command.HelpCommand; +import com.example.datacollect.command.ListCommand; +import com.example.datacollect.repository.ArticleRepository; +import com.example.datacollect.strategy.StrategyFactory; +import com.example.datacollect.view.ConsoleView; +import java.util.HashMap; +import java.util.Map; + +public class CrawlerController { + private final Map commands = new HashMap<>(); + private final ConsoleView view; + private final ArticleRepository repository; + + public CrawlerController(ConsoleView view, ArticleRepository repository, StrategyFactory strategyFactory) { + this.view = view; + this.repository = repository; + register(new HelpCommand(view)); + register(new ListCommand(view)); + register(new CrawlCommand(view, strategyFactory)); + register(new AnalyzeCommand(view, strategyFactory)); + register(new ExitCommand(view)); + } + + private void register(Command command) { + commands.put(command.getName(), command); + } + + public void handle(String input) { + String text = input == null ? "" : input.trim(); + if (text.isEmpty()) { + return; + } + + String[] args = text.split("\\s+"); + String cmdName = args[0].toLowerCase(); + Command command = commands.get(cmdName); + if (command == null) { + view.printError("Unknown command: " + cmdName); + return; + } + command.execute(args, repository); + } +} diff --git a/w10/DefaultStrategy.java b/w10/DefaultStrategy.java new file mode 100644 index 0000000..712b10d --- /dev/null +++ b/w10/DefaultStrategy.java @@ -0,0 +1,40 @@ +package com.example.datacollect.strategy; + +import com.example.datacollect.model.Article; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; + +import java.util.ArrayList; +import java.util.List; + +public class DefaultStrategy implements CrawlStrategy { + @Override + public boolean supports(String url) { + return true; + } + + @Override + public int getPriority() { + return Integer.MIN_VALUE; + } + + @Override + public List
parse(String url, Document doc) { + List
articles = new ArrayList<>(); + Elements headings = doc.select("h1, h2, h3, h4, h5, h6"); + for (Element h : headings) { + String text = h.text().trim(); + if (!text.isEmpty()) { + articles.add(new Article(text, url, "")); + } + } + if (articles.isEmpty()) { + String title = doc.title(); + if (!title.isEmpty()) { + articles.add(new Article(title, url, "")); + } + } + return articles; + } +} \ No newline at end of file diff --git a/w10/RegexStrategy.java b/w10/RegexStrategy.java new file mode 100644 index 0000000..9c9de4e --- /dev/null +++ b/w10/RegexStrategy.java @@ -0,0 +1,45 @@ +package com.example.datacollect.strategy; + +import com.example.datacollect.model.Article; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +public class RegexStrategy implements CrawlStrategy { + private final Pattern pattern; + private final int priority; + + public RegexStrategy(String regex, int priority) { + this.pattern = Pattern.compile(regex); + this.priority = priority; + } + + @Override + public boolean supports(String url) { + return pattern.matcher(url).matches(); + } + + @Override + public int getPriority() { + return priority; + } + + @Override + public List
parse(String url, Document doc) { + List
articles = new ArrayList<>(); + Elements items = doc.select("a[href]"); + for (Element item : items) { + String text = item.text(); + if (!text.isEmpty()) { + String href = item.attr("href"); + String fullUrl = href.startsWith("http") ? href : url + href; + articles.add(new Article(text, fullUrl, "")); + } + } + return articles; + } +} \ No newline at end of file diff --git a/w10/StrategyArchitectureAuditReport.java b/w10/StrategyArchitectureAuditReport.java new file mode 100644 index 0000000..d95e4bf --- /dev/null +++ b/w10/StrategyArchitectureAuditReport.java @@ -0,0 +1,85 @@ +package com.example.datacollect.strategy; + +/** + * =============================================== + * 策略模式架构审计报告 + * =============================================== + * + * 一、审计概述 + * ---------- + * 项目采用策略模式(Strategy Pattern)实现网页解析逻辑的可替换性。 + * 主要涉及以下类: + * - CrawlStrategy:策略接口,定义解析器标准 + * - StrategyFactory:策略工厂,负责策略选择 + * - BlogStrategy:博客解析策略 + * - NewsStrategy:新闻解析策略 + * - HnuNewsStrategy:湖大新闻网解析策略 + * + * =============================================== + * + * 二、审计结果 + * ---------- + * + * 【规范项】策略接口与实现类解耦 + * ------------------------------------------ + * 状态:✓ 符合规范 + * + * 分析: + * - CrawlStrategy 接口定义了两个核心方法:parse() 和 supports() + * - 所有策略实现类(BlogStrategy、NewsStrategy、HnuNewsStrategy)都实现该接口 + * - 策略实现类之间无相互依赖,符合单一职责原则 + * - 新增策略只需实现接口,无需修改现有代码(开闭原则) + * + * =============================================== + * + * 【规范项】策略选择逻辑封装 + * ------------------------------------------ + * 状态:⚠ 部分符合,有改进空间 + * + * 分析: + * - 策略选择逻辑封装在 StrategyFactory.getStrategy(url) 中 + * - 选择逻辑为简单线性遍历,返回第一个匹配的策略 + * - 策略注册顺序即匹配顺序(插入顺序) + * + * 问题: + * 1. 无策略优先级机制,策略注册顺序决定匹配结果 + * 2. 无默认策略,当无策略匹配时返回 null,业务层需额外处理 + * 3. 无法支持正则匹配等复杂匹配场景 + * + * =============================================== + * + * 【规范项】策略类越权行为检查 + * ------------------------------------------ + * 状态:✓ 未发现越权行为 + * + * 分析: + * - 所有策略实现类只依赖 Article、Document 等数据模型 + * - 策略类不直接依赖 Repository 层(数据持久化) + * - 策略类不直接依赖 View 层(输出展示) + * - 策略类专注于解析逻辑,符合职责分离原则 + * + * =============================================== + * + * 三、修改建议 + * ---------- + * + * 1. 【高优先级】引入策略优先级机制 + * 建议:在 CrawlStrategy 接口中增加 getPriority() 方法, + * 或在 StrategyFactory 中支持优先级配置。 + * + * 2. 【高优先级】实现默认策略 + * 建议:当无策略匹配时,使用默认策略兜底, + * 避免返回 null 导致业务层 NPE 风险。 + * + * 3. 【中优先级】支持正则匹配策略 + * 建议:新增 RegexStrategy 类,支持基于正则表达式的 URL 匹配。 + * + * 4. 【中优先级】策略冲突检测 + * 建议:在 getStrategy 时检测是否存在多个策略支持同一 URL, + * 如有则记录警告日志或抛出异常。 + * + * =============================================== + */ +public class StrategyArchitectureAuditReport { + // 此类仅用于承载审计报告文档注释,无实际业务逻辑 +} \ No newline at end of file diff --git a/w10/StrategyFactory.java b/w10/StrategyFactory.java new file mode 100644 index 0000000..c916e91 --- /dev/null +++ b/w10/StrategyFactory.java @@ -0,0 +1,50 @@ +package com.example.datacollect.strategy; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +public class StrategyFactory { + private final List strategies = new ArrayList<>(); + private final CrawlStrategy defaultStrategy; + + public StrategyFactory() { + strategies.add(new HnuNewsStrategy()); + strategies.add(new BlogStrategy()); + strategies.add(new NewsStrategy()); + strategies.add(new RegexStrategy(".*\\.edu\\.cn$", 80)); + strategies.add(new RegexStrategy(".*\\.com$", 60)); + strategies.sort(Comparator.comparingInt(CrawlStrategy::getPriority).reversed()); + this.defaultStrategy = new DefaultStrategy(); + } + + public CrawlStrategy getStrategy(String url) { + List matched = new ArrayList<>(); + for (CrawlStrategy s : strategies) { + if (s.supports(url)) { + matched.add(s); + } + } + if (matched.isEmpty()) { + return defaultStrategy; + } + if (matched.size() > 1) { + System.out.println("WARNING: Multiple strategies matched for URL: " + url + + ", using highest priority: " + matched.get(0).getClass().getSimpleName()); + } + return matched.get(0); + } + + public void register(CrawlStrategy strategy) { + strategies.add(strategy); + strategies.sort(Comparator.comparingInt(CrawlStrategy::getPriority).reversed()); + } + + public List getAllStrategies() { + return new ArrayList<>(strategies); + } + + public CrawlStrategy getDefaultStrategy() { + return defaultStrategy; + } +} diff --git a/w10/java-cli/.gitignore b/w10/java-cli/.gitignore new file mode 100644 index 0000000..0ebcf1a --- /dev/null +++ b/w10/java-cli/.gitignore @@ -0,0 +1,4 @@ +*.jar +*.jar +*.class +*.log \ No newline at end of file diff --git a/w10/java-cli/W10 PPT.md b/w10/java-cli/W10 PPT.md new file mode 100644 index 0000000..d4ba310 --- /dev/null +++ b/w10/java-cli/W10 PPT.md @@ -0,0 +1,492 @@ +--- +id: "24" +title: w10-设计模式 +slug: w10-design-patterns +status: draft +view_count: 0 +created_at: 2026-05-07T12:00:00+08:00 +updated_at: 2026-05-07T14:00:00.000000000+08:00 +--- + +# 高级程序设计 · 第10周 + +### 设计模式:灵活性与可扩展性 + +### 策略模式 + 工厂 + Repository 实战 + +--- + +### 📌 本周导航 + +- W9回顾:骨架的成就与隐患 +- 策略模式:解析器的“插头标准” +- 解析器工厂:自动匹配的魔法 +- Repository:武装数据访问 +- 整体架构串联:调用链全程 +- 代码落地 + 实践任务 +- 架构反思 + W11 预告 + +--- + +## 1️⃣ W9回顾:骨架的成就与隐患 + +### 我们建了一座漂亮的房子 + +- ✅ MVC 分层清晰 +- ✅ Command 模式:**新增命令,Controller 零改动** +- ✅ 所有输出走 `ConsoleView` +- ✅ 工程包结构标准 + +--- + +### 但问题也随之而来 + +```java +// CrawlCommand 里解析逻辑怎么办? +if (url.contains("blog.example.com")) { + // 博客解析... +} else if (url.contains("news.example.com")) { + // 新闻解析... +} else { + view.printError("Unsupported website!"); +} +``` + +> 😫 每支持一个新网站,就要加一个 `else if` + +--- + +### 还有另一个“裸奔”的数据 + +```java +List
articles = new ArrayList<>(); +// 所有 Command 都可以: +articles.clear(); +articles.add(null); +articles.remove(0); +``` + +> 🚨 数据没有任何保护,靠口头约定是靠不住的 + +--- + +### 本周任务 + +1. **解析逻辑可插拔** → 策略模式 + 工厂 +2. **数据访问加守卫** → Repository 模式 + +> W9 搭骨架,W10 装盔甲 + +--- + +## 2️⃣ 策略模式:解析器的“插头标准” + +### 墙上的插座,为什么什么电器都能插? + +- **三孔插座** 是标准接口 +- 电视、电脑、手机充电器都实现这个接口 +- 插座不关心你是什么电器 + +--- + +### 爬虫的世界也一样 + +- `CrawlStrategy` = 插座接口 +- `BlogStrategy`、`NewsStrategy` = 具体电器 +- `CrawlCommand` = 使用电器的人 +- `StrategyFactory` = 插座面板 + +--- + +### 接口即合同 + +```java +public interface CrawlStrategy { + List
parse(String url, Document doc); + boolean supports(String url); +} +``` + +- `supports()`:我能不能处理这个 URL? +- `parse()`:怎么解析? +- **任何网站想被爬,签这份合同!** + +--- + +### 策略 vs 硬编码 + +| 维度 | if-else 屎山 | 策略模式 | +|------|-------------|----------| +| 新增网站 | 改 Command | 新建策略类 | +| 修改解析 | 翻找 else if | 只改对应类 | +| 测试 | 启动整个爬虫 | 单独测策略 | +| 开闭原则 | ❌ 修改开放 | ✅ 扩展开放,修改关闭 | + +--- + +### 具体策略示例 + +```java +public class BlogStrategy implements CrawlStrategy { + public boolean supports(String url) { + return url.contains("blog.example.com"); + } + public List
parse(String url, Document doc) { + List
articles = new ArrayList<>(); + for (Element e : doc.select(".post-title")) { + articles.add(new Article(e.text(), url, "")); + } + return articles; + } +} +``` + +> ✨ 一个新网站,一个独立类,各扫门前雪 + +--- + +## 3️⃣ 解析器工厂:自动匹配的魔法 + +### 谁来选择策略? + +- 如果 `CrawlCommand` 遍历所有策略 → 策略模式白用了 +- 我们需要一个黑盒子:**丢入 URL,返回合适的解析器** + +--- + +### 工厂登场 + +```java +public class StrategyFactory { + private final List strategies = new ArrayList<>(); + + public StrategyFactory() { + strategies.add(new BlogStrategy()); + strategies.add(new NewsStrategy()); + } + + public CrawlStrategy getStrategy(String url) { + for (CrawlStrategy s : strategies) { + if (s.supports(url)) return s; + } + return null; + } +} +``` + +> 🔧 新增网站只需:新建策略类 + 工厂里注册一行 + +--- + +### 开闭原则的胜利 + +- ✅ `CrawlCommand` 完全不改 +- ✅ 新增 `XxxStrategy` 和一行注册 +- ✅ 所有策略的调用方式完全一致 + +> 这就是 **“对扩展开放,对修改关闭”** + +--- + +### 重构后的 CrawlCommand + +```java +public void execute(String[] args, ArticleRepository repository) { + String url = args[1]; + CrawlStrategy strategy = strategyFactory.getStrategy(url); + if (strategy == null) { + view.printError("No strategy for: " + url); + return; + } + Document doc = Jsoup.connect(url).get(); + List
parsed = strategy.parse(url, doc); + for (Article a : parsed) { + repository.add(a); + } + view.printSuccess("Crawled " + parsed.size() + " articles."); +} +``` + +> 🧠 CrawlCommand 现在只做 **“调度”**,不做解析 + +--- + +## 4️⃣ Repository:武装数据访问 + +### 共享 List 的问题 + +```java +articles.clear(); // 清空 +articles.add(null); // 塞 null +articles.remove(0); // 随意删除 +``` + +> 靠约定维护的秩序,终将被打破 + +--- + +### 给数据装上防盗门 + +```java +public class ArticleRepository { + private final List
articles = new ArrayList<>(); + + public void add(Article article) { + if (article == null) throw new IllegalArgumentException(...); + articles.add(article); + } + + public List
getAll() { + return Collections.unmodifiableList(articles); + } + + public int size() { return articles.size(); } + + public void clear() { articles.clear(); } +} +``` + +--- + +### 三道防线 + +| 机制 | 作用 | +|------|------| +| **add 拒绝 null** | 规则写在代码里,不靠口头约定 | +| **getAll 返回不可变视图** | 任何修改立即抛异常 | +| **必须通过 repository 访问** | 封装内部结构,只暴露安全方法 | + +--- + +### 所有 Command 签名改变 + +```java +// W9 +public void execute(String[] args, List
articles); + +// W10 +public void execute(String[] args, ArticleRepository repository); +``` + +> 语义变化:从“给你数据随便玩” → “给你安全的存取通道” + +--- + +## 5️⃣ 整体架构串联 + +### 一个 `crawl` 命令的完整旅程 + +``` +用户输入 "crawl https://blog.example.com" + ↓ +ConsoleView 解析 + ↓ +Controller 路由 → CrawlCommand + ↓ +StrategyFactory.getStrategy(url) → BlogStrategy + ↓ +Jsoup 抓取 → Document + ↓ +BlogStrategy.parse(url, doc) → List
+ ↓ +Repository.add() 存储 + ↓ +ConsoleView 输出成功信息 +``` + +--- + +### 架构全景图 + +![mvc-strategy-repo](/api/v1/attachments/8 "width=70% center") + +```mermaid +flowchart TD + User(["👤 用户输入
crawl https://blog.example.com"]) --> View + + subgraph View["🎨 View 层 (ConsoleView)"] + ReadLine["readLine()"] + Display["display() / printSuccess()"] + end + + ReadLine --> Controller + + subgraph Controller["🧭 Controller 层"] + Router["CrawlerController
Map 路由"] + end + + Router --> Command + + subgraph Command["⚡ Command 层"] + CrawlCmd["CrawlCommand
(调度者)"] + end + + CrawlCmd --> Factory + + subgraph Strategy["🧩 Strategy 层"] + Factory["StrategyFactory
(自动匹配)"] + StrategyI["<> CrawlStrategy"] + BlogS["BlogStrategy"] + NewsS["NewsStrategy"] + Factory --> StrategyI --> BlogS + StrategyI --> NewsS + end + + BlogS --> Repository + + subgraph Repository["🔐 Repository 层"] + Repo["ArticleRepository
(add / getAll)"] + RepoList["List
(私有)"] + Repo --> RepoList + end + + RepoList --> Model + + subgraph Model["📦 Model 层"] + Article["Article"] + end + + CrawlCmd --> Display + Repository --> Display +``` + +> 🗺️ 每一层都有清晰的职责,每一处扩展都只需要新增而不是修改 + +--- + +## 6️⃣ 代码落地(分步升级) + +### 从 W9 升级到 W10 的改动清单 + +1. 新建 `strategy/` 包 → `CrawlStrategy` 接口 +2. 实现 `BlogStrategy`、`NewsStrategy` +3. 实现 `StrategyFactory` +4. 新建 `repository/` 包 → `ArticleRepository` +5. 修改 `Command` 接口签名 +6. 重写 `CrawlCommand` +7. 调整其他所有 `Command` +8. 调整 `Controller` 和 `App.java` + +--- + +### 关键代码演示 + +- `Collections.unmodifiableList()` 的用法 +- `StrategyFactory.getStrategy()` 的遍历逻辑 +- `CrawlCommand` 从“写死解析”到“调度组装” + +```java +// 一个改动示例 +for (Article a : parsed) { + repository.add(a); // 旧: articles.add(a); +} +``` + +--- + +### 找茬点 + +- `StrategyFactory` 没匹配到策略时返回 `null` +- `CrawlCommand` 检查 `null` 并报错 +- 有没有更优雅的方式避免 `null` 判断? + +> 🔍 课后用 AI 探索 “空对象模式” 的前奏 + +--- + +## 7️⃣ 架构反思 + 下周预告 + +### 当前架构的脆弱点 + +- ❌ 异常处理单一笼统 +- ❌ 没有重试机制 +- ❌ 网络超时无控制 +- ❌ 日志仅输出到终端 + +--- + +### W11 目标:健壮性工程 + +- ✅ **自定义异常体系**:把“出错了”变成具体的业务异常 +- ✅ **工程化日志**:记录谁、什么时间、做了什么 +- ✅ **防御式编程 + 重试机制**:网络抖动不再致命 + +> W9 搭骨架 → W10 装盔甲 → W11 让它经得起毒打 + +--- + +## 8️⃣ 实践任务(现场) + +### 必做 + +1. 基于 W9 项目升级到 W10 +2. 至少实现 2 个 CrawlStrategy(可模拟) +3. 实现 `StrategyFactory` 和 `ArticleRepository` +4. 测试完整 `crawl` → `list` 流程 + +### 验收标准 + +- [ ] 新增策略只加类+注册,零改动旧代码 +- [ ] `getAll()` 返回不可修改视图 +- [ ] `CrawlCommand` 不含网站特定解析 +- [ ] 所有 Command 用 Repository +- [ ] 无地方直接操作 `List
` + +--- + +## 9️⃣ 课后作业 + +### 必做 + +1. 完善 `ArticleRepository`:增加 `addAll`,防御 null +2. **★ AnalyzeCommand**:复用策略解析但不存储,输出统计信息 +3. **AI 架构审计**:发送类签名给 AI,检查策略解耦与封装 + +### 选做 + +- 正则策略匹配、默认策略、策略优先级 +- 思考题:两个策略都 `supports` 同一 URL 时怎么办? + +--- + +## 🤖 AI 协同升级 + +### 架构审计师(必做) + +- 画出类依赖图 +- 发给 AI:“检查开闭原则达成度,Repository 封装完备性,是否存在循环依赖” + +### 进阶探究 + +- 不用工厂,直接用 `Map` 存起来 vs `StrategyFactory` 的区别? + +--- + +## 📚 总结 + +- ✅ 策略模式:算法可插拔,新增网站零痛苦 +- ✅ 工厂:自动匹配,URL → 策略的魔法 +- ✅ Repository:数据守卫,规则从口头约定变成代码强制 +- ✅ 架构:从“分开”到“优雅合上”,对扩展开放,对修改关闭 + +### W11 预告 + +自定义异常体系 + 日志 + 重试机制 + +> 🚀 让我们造的爬虫,经得住现实的考验 + +--- + +## 谢谢! + +**保持工程洁癖,下周见!** + +--- + +# 居中标题 + +## 居中副标题 + +### 居中内容 + +--- \ No newline at end of file diff --git a/w10/java-cli/pom.xml b/w10/java-cli/pom.xml new file mode 100644 index 0000000..24624f6 --- /dev/null +++ b/w10/java-cli/pom.xml @@ -0,0 +1,52 @@ + + 4.0.0 + com.example + datacollect-cli + 0.1.0 + + 11 + 11 + + + + org.jsoup + jsoup + 1.17.2 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + org.apache.maven.plugins + maven-assembly-plugin + 3.3.0 + + + + com.example.datacollect.Main + + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + + + + diff --git a/w10/java-cli/src/main/java/com/example/datacollect/Main.java b/w10/java-cli/src/main/java/com/example/datacollect/Main.java new file mode 100644 index 0000000..d179115 --- /dev/null +++ b/w10/java-cli/src/main/java/com/example/datacollect/Main.java @@ -0,0 +1,21 @@ +package com.example.datacollect; + +import com.example.datacollect.controller.CrawlerController; +import com.example.datacollect.repository.ArticleRepository; +import com.example.datacollect.strategy.StrategyFactory; +import com.example.datacollect.view.ConsoleView; + +public class Main { + + public static void main(String[] args) { + ConsoleView view = new ConsoleView(); + ArticleRepository repository = new ArticleRepository(); + StrategyFactory strategyFactory = new StrategyFactory(); + CrawlerController controller = new CrawlerController(view, repository, strategyFactory); + + view.printSuccess("Welcome to CLI Crawler (w10_3)! Type help for commands."); + while (true) { + controller.handle(view.readLine()); + } + } +} diff --git a/w10/java-cli/src/main/java/com/example/datacollect/command/Command.java b/w10/java-cli/src/main/java/com/example/datacollect/command/Command.java new file mode 100644 index 0000000..029cadc --- /dev/null +++ b/w10/java-cli/src/main/java/com/example/datacollect/command/Command.java @@ -0,0 +1,8 @@ +package com.example.datacollect.command; + +import com.example.datacollect.repository.ArticleRepository; + +public interface Command { + String getName(); + void execute(String[] args, ArticleRepository repository); +} diff --git a/w10/java-cli/src/main/java/com/example/datacollect/command/CrawlCommand.java b/w10/java-cli/src/main/java/com/example/datacollect/command/CrawlCommand.java new file mode 100644 index 0000000..0841d57 --- /dev/null +++ b/w10/java-cli/src/main/java/com/example/datacollect/command/CrawlCommand.java @@ -0,0 +1,50 @@ +package com.example.datacollect.command; + +import com.example.datacollect.repository.ArticleRepository; +import com.example.datacollect.strategy.CrawlStrategy; +import com.example.datacollect.strategy.StrategyFactory; +import com.example.datacollect.view.ConsoleView; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; + +public class CrawlCommand implements Command { + private final ConsoleView view; + private final StrategyFactory strategyFactory; + + public CrawlCommand(ConsoleView view, StrategyFactory strategyFactory) { + this.view = view; + this.strategyFactory = strategyFactory; + } + + @Override + public String getName() { + return "crawl"; + } + + @Override + public void execute(String[] args, ArticleRepository repository) { + if (args.length < 2) { + view.printError("Usage: crawl "); + return; + } + String url = args[1]; + + CrawlStrategy strategy = strategyFactory.getStrategy(url); + if (strategy == null) { + view.printError("No strategy found for: " + url); + return; + } + + try { + view.printInfo("Crawling: " + url); + Document doc = Jsoup.connect(url).get(); + var articles = strategy.parse(url, doc); + for (var article : articles) { + repository.add(article); + } + view.printSuccess("Crawled " + articles.size() + " articles."); + } catch (Exception e) { + view.printError("Failed to crawl: " + e.getMessage()); + } + } +} diff --git a/w10/java-cli/src/main/java/com/example/datacollect/command/ExitCommand.java b/w10/java-cli/src/main/java/com/example/datacollect/command/ExitCommand.java new file mode 100644 index 0000000..eafcd1d --- /dev/null +++ b/w10/java-cli/src/main/java/com/example/datacollect/command/ExitCommand.java @@ -0,0 +1,23 @@ +package com.example.datacollect.command; + +import com.example.datacollect.repository.ArticleRepository; +import com.example.datacollect.view.ConsoleView; + +public class ExitCommand implements Command { + private final ConsoleView view; + + public ExitCommand(ConsoleView view) { + this.view = view; + } + + @Override + public String getName() { + return "exit"; + } + + @Override + public void execute(String[] args, ArticleRepository repository) { + view.printSuccess("Bye!"); + System.exit(0); + } +} diff --git a/w10/java-cli/src/main/java/com/example/datacollect/command/HelpCommand.java b/w10/java-cli/src/main/java/com/example/datacollect/command/HelpCommand.java new file mode 100644 index 0000000..dd7a175 --- /dev/null +++ b/w10/java-cli/src/main/java/com/example/datacollect/command/HelpCommand.java @@ -0,0 +1,22 @@ +package com.example.datacollect.command; + +import com.example.datacollect.repository.ArticleRepository; +import com.example.datacollect.view.ConsoleView; + +public class HelpCommand implements Command { + private final ConsoleView view; + + public HelpCommand(ConsoleView view) { + this.view = view; + } + + @Override + public String getName() { + return "help"; + } + + @Override + public void execute(String[] args, ArticleRepository repository) { + view.printInfo("Commands: crawl , list, help, exit"); + } +} diff --git a/w10/java-cli/src/main/java/com/example/datacollect/command/ListCommand.java b/w10/java-cli/src/main/java/com/example/datacollect/command/ListCommand.java new file mode 100644 index 0000000..8147be8 --- /dev/null +++ b/w10/java-cli/src/main/java/com/example/datacollect/command/ListCommand.java @@ -0,0 +1,22 @@ +package com.example.datacollect.command; + +import com.example.datacollect.repository.ArticleRepository; +import com.example.datacollect.view.ConsoleView; + +public class ListCommand implements Command { + private final ConsoleView view; + + public ListCommand(ConsoleView view) { + this.view = view; + } + + @Override + public String getName() { + return "list"; + } + + @Override + public void execute(String[] args, ArticleRepository repository) { + view.display(repository.getAll()); + } +} diff --git a/w10/java-cli/src/main/java/com/example/datacollect/model/Article.java b/w10/java-cli/src/main/java/com/example/datacollect/model/Article.java new file mode 100644 index 0000000..147dbe6 --- /dev/null +++ b/w10/java-cli/src/main/java/com/example/datacollect/model/Article.java @@ -0,0 +1,45 @@ +package com.example.datacollect.model; + +public class Article { + private String title; + private String url; + private String content; + + public Article(String title, String url, String content) { + this.title = title; + this.url = url; + this.content = content; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + @Override + public String toString() { + return "Article{" + + "title='" + title + '\'' + + ", url='" + url + '\'' + + '}'; + } +} diff --git a/w10/java-cli/src/main/java/com/example/datacollect/strategy/BlogStrategy.java b/w10/java-cli/src/main/java/com/example/datacollect/strategy/BlogStrategy.java new file mode 100644 index 0000000..1e23b2b --- /dev/null +++ b/w10/java-cli/src/main/java/com/example/datacollect/strategy/BlogStrategy.java @@ -0,0 +1,25 @@ +package com.example.datacollect.strategy; + +import com.example.datacollect.model.Article; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import java.util.ArrayList; +import java.util.List; + +public class BlogStrategy implements CrawlStrategy { + @Override + public boolean supports(String url) { + return url.contains("blog.example.com"); + } + + @Override + public List
parse(String url, Document doc) { + List
articles = new ArrayList<>(); + Elements titles = doc.select(".post-title"); + for (Element e : titles) { + articles.add(new Article(e.text(), url, "")); + } + return articles; + } +} diff --git a/w10/java-cli/src/main/java/com/example/datacollect/strategy/HnuNewsStrategy.java b/w10/java-cli/src/main/java/com/example/datacollect/strategy/HnuNewsStrategy.java new file mode 100644 index 0000000..5ad3866 --- /dev/null +++ b/w10/java-cli/src/main/java/com/example/datacollect/strategy/HnuNewsStrategy.java @@ -0,0 +1,49 @@ +package com.example.datacollect.strategy; + +import com.example.datacollect.model.Article; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import java.util.ArrayList; +import java.util.List; + +public class HnuNewsStrategy implements CrawlStrategy { + @Override + public boolean supports(String url) { + return url.contains("news.hnu.edu.cn"); + } + + @Override + public List
parse(String url, Document doc) { + List
articles = new ArrayList<>(); + Elements listItems = doc.select("ul.list11 li"); + + for (Element li : listItems) { + Element link = li.selectFirst("a"); + if (link == null) continue; + + String articleUrl = link.attr("href"); + if (!articleUrl.startsWith("http")) { + articleUrl = "https://news.hnu.edu.cn" + articleUrl.replace("..", ""); + } + + String title = ""; + Element titleEl = link.selectFirst("h4.l2.h4s2"); + if (titleEl != null) { + title = titleEl.text().trim(); + } + + String content = ""; + Element contentEl = link.selectFirst("p.l3.ps3"); + if (contentEl != null) { + content = contentEl.text().trim(); + } + + if (!title.isEmpty()) { + articles.add(new Article(title, articleUrl, content)); + } + } + + return articles; + } +} diff --git a/w10/java-cli/src/main/java/com/example/datacollect/strategy/NewsStrategy.java b/w10/java-cli/src/main/java/com/example/datacollect/strategy/NewsStrategy.java new file mode 100644 index 0000000..f6eb4bd --- /dev/null +++ b/w10/java-cli/src/main/java/com/example/datacollect/strategy/NewsStrategy.java @@ -0,0 +1,25 @@ +package com.example.datacollect.strategy; + +import com.example.datacollect.model.Article; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import java.util.ArrayList; +import java.util.List; + +public class NewsStrategy implements CrawlStrategy { + @Override + public boolean supports(String url) { + return url.contains("news.example.com"); + } + + @Override + public List
parse(String url, Document doc) { + List
articles = new ArrayList<>(); + Elements items = doc.select(".article-headline"); + for (Element e : items) { + articles.add(new Article(e.text(), url, "")); + } + return articles; + } +} diff --git a/w10/java-cli/src/main/java/com/example/datacollect/view/ConsoleView.java b/w10/java-cli/src/main/java/com/example/datacollect/view/ConsoleView.java new file mode 100644 index 0000000..3c1d47a --- /dev/null +++ b/w10/java-cli/src/main/java/com/example/datacollect/view/ConsoleView.java @@ -0,0 +1,42 @@ +package com.example.datacollect.view; + +import com.example.datacollect.model.Article; +import java.util.List; +import java.util.Scanner; + +public class ConsoleView { + private static final String ANSI_RESET = "\u001B[0m"; + private static final String ANSI_GREEN = "\u001B[32m"; + private static final String ANSI_RED = "\u001B[31m"; + private static final String ANSI_BLUE = "\u001B[34m"; + + private final Scanner scanner = new Scanner(System.in); + + public String readLine() { + System.out.print("> "); + return scanner.nextLine(); + } + + public void printSuccess(String msg) { + System.out.println(ANSI_GREEN + msg + ANSI_RESET); + } + + public void printError(String msg) { + System.out.println(ANSI_RED + msg + ANSI_RESET); + } + + public void printInfo(String msg) { + System.out.println(ANSI_BLUE + msg + ANSI_RESET); + } + + public void display(List
articles) { + if (articles.isEmpty()) { + printInfo("暂无文章,请先执行 crawl。"); + return; + } + for (int i = 0; i < articles.size(); i++) { + Article a = articles.get(i); + System.out.println((i + 1) + ". " + a.getTitle() + " | " + a.getUrl()); + } + } +} diff --git a/w10/java-cli/target/W9工程架构 - 教案v3.md b/w10/java-cli/target/W9工程架构 - 教案v3.md new file mode 100644 index 0000000..09de868 --- /dev/null +++ b/w10/java-cli/target/W9工程架构 - 教案v3.md @@ -0,0 +1,758 @@ +--- + +# 教案:《高级程序设计》第9周——工程架构:从"写代码"到"造系统" + +| 项目 | 内容 | +|------|------| +| **课程名称** | 高级程序设计 | +| **周次** | 第9周 | +| **主题** | 工程架构——从"写代码"到"造系统" | +| **学时** | 2学时(90分钟) | +| **授课对象** | 具备Python基础、已完成Java面向对象特性学习的学生 | +| **教学环境** | JDK 17+、IntelliJ IDEA、Maven(模板) | +| **前情提要** | 本课程原计划使用JavaFX GUI,后根据教学反馈转向CLI + MVC + 爬虫工程化 | + +--- + +## 教学调整说明:为什么选择CLI而不是GUI? + +> **原计划**:JavaFX桌面应用 → **新计划**:CLI命令行应用 + +| 维度 | GUI (JavaFX) | CLI (命令行) | +|------|--------------|-------------| +| **学习重心** | 布局、控件、事件监听 | 架构、分层、命令路由 | +| **学生痛点** | "窗口点击"与后端能力无关 | 真正锻炼工程思维 | +| **AI辅助** | AI生成FXML,学生看不懂 | AI辅助重构架构 | +| **工程化** | 脱离真实后端开发场景 | 模拟真实服务器/大数据开发 | +| **核心转型** | "视觉装饰"优先 | "逻辑架构"优先 | + +**决策理由**: +1. **985学生需要的是工程思维**,不是拖控件 +2. **接口抽象**是弱项,CLI + MVC更能暴露这个问题 +3. **彩色终端**足够酷炫,且代码量可控 + +**更深层的教育价值**: +> 在GUI框架中,架构已被框架强制划定,学生只是"遵守规矩";而CLI世界里没有任何框架告诉你模型在哪、视图在哪——**当外部约束消失,内部的工程纪律才真正建立**。这正是本节课要传递的核心精神。 + +--- + +## 一、教学目标 + +| 目标维度 | 具体描述 | +|----------|----------| +| **知识掌握** | 理解MVC架构的职责划分及其演化脉络;掌握Maven项目结构与pom.xml基础;理解Command模式的路由原理。 | +| **工程实践** | 能搭建规范的Maven项目包结构;能实现基于Scanner的控制台交互;能用Command接口实现可扩展的命令路由;能识别架构中的"越权行为"。 | +| **思维转型** | 从"一个类写全部"转向"分层解耦";从"修改现有代码"转向"新增类实现功能";从"满足功能"转向"代码的工程洁癖"。 | +| **工具应用** | 利用AI辅助审查MVC职责越权;让AI扮演"架构审计师"检查分层是否清晰;理解AI生成代码中的架构缺陷。 | + +--- + +## 二、教学重点与难点 + +| 项目 | 内容 | 突破方法 | +|------|------|----------| +| **重点** | MVC三层职责划分、CLI交互实现、Command接口解耦、代码中的工程细节(常量、输出归属) | 以"新增命令需要改什么"为切入点,展示Command模式的优势;通过现场"代码找茬"强化细节意识 | +| **难点** | Controller不写业务逻辑、Command接口的多态实现、共享数据模型的设计缺陷识别 | 现场演示:增加一个命令只需新建类,无需修改Controller;暴露`List
`共享引用的问题并预告解决方案 | + +--- + +## 三、教学过程设计(90分钟) + +| 环节 | 时间 | 教学内容 | 师生活动 | AI协同点 | +|------|------|----------|----------|----------| +| **1. 痛点引入:从脚本到工程的鸿沟** | 10' | 展示"意大利面"式爬虫代码,演示改一处需要动全身 | **教师演示**:现场展示一段混乱代码,让学生找问题 | 用AI分析代码耦合度 | +| **2. CLI vs GUI:架构选择的思考** | 10' | 对比两种方案的优缺点,解释为什么CLI更适合培养工程思维 | **教师讲解**:用对比表格说明选择CLI的理由 | — | +| **3. MVC分层设计** | 20' | 讲解Model/View/Controller三层职责,用"餐厅类比"强化理解,随后批判类比局限性 | **教师讲解**:配合架构图讲解三层交互,引导学生寻找类比破绽 | 用AI生成MVC职责对照表 | +| **4. Command模式:可扩展的命令路由** | 15' | 引入Command接口,解释"一个命令就是一个类" | **类比**:Command像酒店的服务部门,Controller是前台 | 让AI解释Command模式的多态原理 | +| **5. Maven模板与环境** | 5' | 直接使用提供的Maven模板,讲解目录结构 | **教师演示**:解压模板 → IDEA打开 → 运行 | — | +| **6. 三层代码落地** | 20' | **Model**:Article实体
**View**:ConsoleView(ANSI常量)
**Command接口**+实现
**Controller**:Map路由 | **教师演示**:分步写出代码,刻意埋入1~2个"越权细节"让学生找茬 | 学生用AI做"架构审计" | +| **7. 架构反思与展望** | 5' | 指出当前`List
`共享引用的问题,预告W10策略模式与仓库层 | **师生互动**:你发现这个设计有什么风险? | 让AI分析共享可变状态的危害 | +| **8. 实践任务:空壳程序** | 5' | 搭建完整包结构,实现CLI循环 | 学生现场编码,教师巡视 | 完成后用AI检查包结构 | +| **9. 总结与过渡** | 5' | 本周实现了"骨架+命令可扩展",下周填入"灵魂"——解析器,并解决数据安全问题 | 总结Command模式优势,预告策略模式 | — | + +--- + +## 四、核心教学内容脚本 + +### 4.1 痛点引入:从脚本到工程的鸿沟(10分钟) + +**教师口播**: +> "同学们,前8周我们学的是Java语法,从变量到类,从继承到接口。但有一个问题:代码写完之后,怎么组织?" +> +> "来看这段代码——这是某个同学写的'爬虫',他一个人完成了一个'完整'的项目。" + +**展示"脚本式"代码**: +```java +public class Crawler { + public static void main(String[] args) { + System.out.print("请输入URL: "); + Scanner scanner = new Scanner(System.in); + String url = scanner.nextLine(); + + List titles = new ArrayList(); + try { + Document doc = Jsoup.connect(url).get(); + Elements elements = doc.select(".post-title"); + for (Element e : elements) { + String title = e.text(); + System.out.println("标题: " + title); + titles.add(title); + } + } catch (Exception ex) { + System.out.println("出错啦: " + ex.getMessage()); + } + } +} +``` + +**提问引导**: +1. "如果我想把标题保存到文件,要改哪里?" +2. "如果我想支持另一个网站,它的HTML结构不一样,要怎么办?" +3. "如果我想让输出变成彩色,要改哪里?" + +**痛点提炼**: +> "看到了吗?才60行代码,已经'牵一发而动全身'了。这就是一个'脚本'的宿命——功能全混在一起,改一个小需求,整个文件都要翻。" +> +> "这周我们要解决:**怎么让代码'改起来不疼'?**" + +--- + +### 4.2 CLI vs GUI:架构选择的思考(10分钟) + +**教师口播**: +> "既然要写一个'完整'的爬虫应用,我们有两个选择:图形界面(GUI)或命令行界面(CLI)。为什么我推荐CLI而不是GUI?" + +**对比表格** + +| 维度 | GUI (JavaFX) | CLI (命令行) | +|------|--------------|-------------| +| **代码量** | FXML + Controller + CSS,大量模板代码 | 纯Java,代码量可控 | +| **学习重心** | 布局、控件、事件监听 | 架构、分层、命令路由 | +| **后端能力** | 几乎无关 | 模拟真实服务器开发 | +| **可测试性** | 难(需要UI测试框架) | 易(直接测试Command类) | +| **工程思维** | 弱(关注视觉) | 强(关注逻辑) | + +**核心观点**: +> **CLI更需要MVC!** GUI有现成的事件系统(点击按钮→触发事件),而CLI只有字符流。**没有架构,分分钟写成脚本**。MVC在CLI里是"刚需",不是"装饰"。 +> +> **更深一层**:在GUI里,框架已经硬塞给你一套架构,你只是在填空;但在CLI里,所有结构都必须由你亲手搭建。**当外部约束消失,内部的工程纪律才真正开始建立**——这才是本节课的真正目的。 + +**CLI也能很酷**: +- ANSI彩色输出(红/绿/黄/蓝) +- 表格展示数据 +- 进度条动画 +- 模拟真实大数据开发场景 + +--- + +### 4.3 MVC分层设计(20分钟) + +#### 4.3.1 MVC的起源与演进 + +**教师口播**: +> "MVC不是新东西,它是1970年代为桌面应用设计的架构思想。但它的核心——'职责分离'——在任何软件里都适用。" + +| 年代 | 场景 | MVC的角色 | +|------|------|----------| +| 1970s | Smalltalk-72 GUI | 最早的用户界面架构 | +| 1990s | Web开发 (Struts) | 后端模板引擎 | +| 2000s | ASP.NET MVC | 现代Web框架 | +| 2020s | CLI + API | 解耦业务逻辑与表现层 | + +#### 4.3.2 从GUI到CLI的映射 + +| GUI组件 | CLI对应 | 说明 | +|--------|--------|------| +| 窗口/按钮 | 命令行输入 | **View = 用户交互** | +| 数据模型 | Article实体类 | **Model = 数据结构** | +| 事件监听 | Command路由 | **Controller = 调度** | + +#### 4.3.3 MVC三层职责 + +**架构图示**: + +``` +┌─────────────────────────────────────────┐ +│ 入口 │ +│ (main方法) │ +└─────────────────┬───────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Controller │ +│ - 接收命令(crawl, help, exit) │ +│ - 分发给对应的Command │ +│ 【口诀】:Controller不管"怎么做", │ +│ 只管"派给谁" │ +└─────────┬───────────────┬───────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Model │ │ View │ +│ - 数据实体 │ │ - 输入解析 │ +│ - 业务逻辑 │ │ - 输出格式化 │ +│ 【口诀】: │ │ 【口诀】: │ +│ Model管"数据" │ │ View管"呈现" │ +└─────────────────┘ └─────────────────┘ +``` + +**三层职责详解** + +| 层级 | 职责 | 典型代码 | 禁止做什么 | +|------|------|----------|------------| +| **Model** | 数据结构 + 业务逻辑 | `class Article { String title; String content; }` | 不能有`System.out.println`,不能有`Scanner` | +| **View** | 接收用户输入 + 格式化输出 | `class ConsoleView { String readInput(); void print(String); }` | 不能写爬虫逻辑,只做"传声筒" | +| **Controller** | 协调调度 | `class CrawlerController { void handle(String cmd) { ... } }` | 不能直接写业务细节,委托给Command | + +#### 4.3.4 类比强化:"餐厅类比" + +> "把MVC想象成一家餐厅: +> - **Model是后厨**:只管做菜(数据加工),不管谁来吃、怎么端 +> - **View是服务员**:只管端菜和收钱(输入输出),不管菜怎么做 +> - **Controller是前台**:只管把顾客的点单传给后厨,把做好的菜端给顾客 +> +> 如果后厨开始管'谁来吃饭',这餐厅就乱了。" + +#### 4.3.5 对"餐厅类比"的批判性思考(关键!) + +**教师导引**: +> "刚才的类比好理解吗?很好。但任何一个类比都有它的边界,如果把它当成真理,就会出问题。现在我们来给这个类比'找茬'。" + +**提问学生**: +1. "后厨真的完全不知道客人是谁吗?如果客人有忌口(比如不吃香菜),这个信息需不需要传到后厨?" +2. "服务员只是端菜吗?在真实餐厅里,服务员经常向后厨反馈'客人觉得今天的菜咸了',这属于View→Model的反向影响吗?" +3. "在这个类比里,我们把前台(Controller)和后厨(Model)的关系说成单向的。但实际上,后厨做完了菜,需要通知前台'菜好了',这不就是**观察者模式**吗?" + +**点明本质**: +> "实际MVC的数据流向常常是**双向**的:Controller调用Model的方法改变数据,Model变化后又通知View更新显示。只不过在本次CLI项目中,我们暂时使用'请求-响应'的单向简化模型——用户输入命令,系统处理,然后立即输出结果。这个简化版够用,但你要知道完整的MVC是更动态的。随着系统复杂,Model层需要一个专门的'仓库类'来管理数据,并通知视图刷新——这正是W10我们将要深入的内容。" + +#### 4.3.6 MVC的数据流向(本课程简化版) + +``` +CLI用户输入 + ↓ +View(解析命令字符串) + ↓ +Controller(找到对应Command) + ↓ +Command.execute()(执行业务逻辑) + ↓ +Model(Article数据,目前暂存于List) + ↓ +View(display()展示数据) + ↓ +CLI终端显示 +``` + +--- + +### 4.4 Command模式:可扩展的命令路由(15分钟) + +**教师口播**: +> "现在引入一个设计模式——Command(命令)模式。它的核心思想是:**一个命令就是一个类**。" + +#### 4.4.1 为什么需要Command模式? + +**演示:增加一个命令的代价(switch-case版)** +```java +// 现状代码 +switch (cmd) { + case "crawl": handleCrawl(); break; + case "help": showHelp(); break; + // 如果要增加 list 命令? + // 1. 加 case "list" + // 2. 加 handleList() 方法 + // 3. 可能还要改其他地方... +} +``` + +**提问**: +- "如果我想增加10个命令,这个类要改多少次?" +- "如果我不小心删了一个case,整个程序还能跑吗?" + +**痛点提炼**: +> "每加一个功能,就要在这个类里戳一个洞。**这就是'肥控制器'陷阱**——所有的逻辑都堆在Controller里,它变成了新的'意大利面'。" + +#### 4.4.2 Command模式的四个要素 + +| 要素 | 角色 | 示例 | +|------|------|------| +| **Command接口** | 抽象的"订单" | `Command` 接口 | +| **ConcreteCommand** | 具体的订单 | `HelpCommand`、`CrawlCommand` | +| **Invoker** | 接单的前台 | `CrawlerController` | +| **Receiver** | 执行者 | `ConsoleView`、`ArticleRepository` | + +#### 4.4.3 Command接口定义 + +```java +// src/main/java/com/crawler/command/Command.java +package com.crawler.command; + +import com.crawler.model.Article; +import java.util.List; + +public interface Command { + String getName(); // 命令名,如 "crawl" + void execute(String[] args, List
articles); // 执行逻辑 +} +``` + +#### 4.4.4 Controller的变革(从switch到Map) + +```java +// 修改后的Controller +public class CrawlerController { + private Map commands; // 用Map存命令 + private ConsoleView view; // 持有View以输出错误 + + public CrawlerController(ConsoleView view, List
articles) { + this.view = view; + this.commands = new HashMap<>(); + // 增加命令无需改Controller代码,只需在这里注册 + commands.put("crawl", new CrawlCommand(view)); + commands.put("help", new HelpCommand(view)); + commands.put("list", new ListCommand(view)); + commands.put("exit", new ExitCommand(view)); + } + + public void handle(String input) { + if (input.isEmpty()) return; + String[] parts = input.split("\\s+"); + String cmd = parts[0].toLowerCase(); + + Command command = commands.get(cmd); + if (command == null) { + view.printError("Unknown command: " + cmd); // 通过View输出,而非直接System.out + return; + } + + // 执行命令,传入参数和文章列表 + command.execute(parts, articles); + } +} +``` + +**对比表格** + +| 维度 | switch-case | Command模式 | +|------|-------------|-------------| +| 增加命令 | 要改Controller | 新建一个类 | +| 多态体验 | 无 | execute()的多态调用 | +| 可测试性 | 难 | 每个Command可单独测试 | +| 代码量 | 少 | 多,但更清晰 | + +**类比强化**: +> "Command模式就像**酒店的客房服务**:每个服务(清理、送餐、按摩)都是一个独立的部门。前台(Controller)只负责接电话,然后把请求'派发'给对应的部门。部门自己知道怎么干活,不需要前台教。" +> +> "如果想新增一个服务,前台只需要'登记'一下,不需要把现有部门重新装修。" + +--- + +### 4.5 Maven模板与环境(5分钟) + +**教师口播**: +> "这周我们不发愁pom.xml配置。我已经把 Maven 模板准备好了,你们只需要解压、打开、运行。" + +**模板使用流程**: +``` +1. 解压 [my-crawler-template.zip] +2. 用 IDEA 打开文件夹 +3. 右键 pom.xml → Maven → Reload Project +4. 运行 App.java +``` + +**标准目录结构**: +``` +src/main/java/com/crawler/ +├── model/ +│ └── Article.java +├── view/ +│ └── ConsoleView.java +├── command/ +│ ├── Command.java (接口) +│ ├── CrawlCommand.java +│ ├── HelpCommand.java +│ ├── ListCommand.java +│ └── ExitCommand.java +└── controller/ + └── CrawlerController.java +``` + +--- + +### 4.6 代码落地(20分钟) + +#### 4.6.1 Model层:Article实体 + +```java +// src/main/java/com/crawler/model/Article.java +package com.crawler.model; + +public class Article { + private String title; + private String url; + private String content; + + public Article(String title, String url, String content) { + this.title = title; + this.url = url; + this.content = content; + } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getUrl() { return url; } + public void setUrl(String url) { this.url = url; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + + @Override + public String toString() { + return "Article{title='" + title + "', url='" + url + "'}"; + } +} +``` + +#### 4.6.2 View层:ANSI常量集中管理(工程细节!) + +```java +// src/main/java/com/crawler/view/ConsoleView.java +package com.crawler.view; + +import com.crawler.model.Article; +import java.util.List; +import java.util.Scanner; + +public class ConsoleView { + // ANSI颜色常量——集中管理,避免散落各处 + private static final String ANSI_GREEN = "\033[32m"; + private static final String ANSI_RED = "\033[31m"; + private static final String ANSI_CYAN = "\033[36m"; + private static final String ANSI_RESET = "\033[0m"; + + private Scanner scanner = new Scanner(System.in); + + public String readLine() { + System.out.print("crawler> "); + return scanner.nextLine().trim(); + } + + public void print(String msg) { + System.out.println(msg); + } + + public void printSuccess(String msg) { + print(ANSI_GREEN + msg + ANSI_RESET); + } + + public void printError(String msg) { + print(ANSI_RED + msg + ANSI_RESET); + } + + public void printInfo(String msg) { + print(ANSI_CYAN + msg + ANSI_RESET); + } + + // 展示文章列表 + public void display(List
articles) { + if (articles.isEmpty()) { + printInfo("No articles yet. Use 'crawl ' first."); + return; + } + print("+----------+--------------------------------+"); + print("| Title | URL |"); + print("+----------+--------------------------------+"); + for (Article a : articles) { + String title = a.getTitle(); + if (title.length() > 10) title = title.substring(0, 10) + ".."; + String url = a.getUrl(); + if (url.length() > 30) url = url.substring(0, 27) + "..."; + print("| " + String.format("%-10s", title) + " | " + url + " |"); + } + print("+----------+--------------------------------+"); + printInfo("Total: " + articles.size() + " articles"); + } +} +``` + +**教师提示**: +> "注意:所有ANSI转义码都被定义为`private static final`常量。如果把`\033[32m`散落在项目各处,一旦想调整颜色,就得满世界去改——这正是我们之前痛批的'意大利面'。**这就是工程细节**。" + +#### 4.6.3 Command接口与四个实现(全部通过View输出) + +```java +// Command.java +public interface Command { + String getName(); + void execute(String[] args, List
articles); +} + +// HelpCommand.java +public class HelpCommand implements Command { + private ConsoleView view; + public HelpCommand(ConsoleView v) { this.view = v; } + public String getName() { return "help"; } + public void execute(String[] args, List
articles) { + view.printInfo("Commands: crawl , list, help, exit"); + } +} + +// ListCommand.java +public class ListCommand implements Command { + private ConsoleView view; + public ListCommand(ConsoleView v) { this.view = v; } + public String getName() { return "list"; } + public void execute(String[] args, List
articles) { + view.display(articles); + } +} + +// CrawlCommand.java (存根) +public class CrawlCommand implements Command { + private ConsoleView view; + public CrawlCommand(ConsoleView v) { this.view = v; } + public String getName() { return "crawl"; } + public void execute(String[] args, List
articles) { + if (args.length < 2) { + view.printError("Usage: crawl "); + return; + } + view.printInfo("Stub: Would crawl " + args[1]); + } +} + +// ExitCommand.java +public class ExitCommand implements Command { + private ConsoleView view; + public ExitCommand(ConsoleView v) { this.view = v; } + public String getName() { return "exit"; } + public void execute(String[] args, List
articles) { + view.printSuccess("Bye!"); // 全部输出都通过View,绝不让System.out直接出现在这里 + System.exit(0); + } +} +``` + +**故意埋设的"找茬点"**: +> "我在刚才的代码里有没有隐藏违反MVC原则的地方?`CrawlCommand`的存根里,`view.printInfo("Stub: Would crawl " + args[1]);` —— 这个字符串拼接算是"业务逻辑"吗?留给大家用AI架构审计时讨论。 + +#### 4.6.4 Controller:Map路由(全部通过View输出) + +```java +// src/main/java/com/crawler/controller/CrawlerController.java +package com.crawler.controller; + +import com.crawler.command.*; +import com.crawler.model.Article; +import com.crawler.view.ConsoleView; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class CrawlerController { + private Map commands = new HashMap<>(); + private ConsoleView view; // 持有View + private List
articles; + + public CrawlerController(ConsoleView view, List
articles) { + this.view = view; + this.articles = articles; + commands.put("help", new HelpCommand(view)); + commands.put("list", new ListCommand(view)); + commands.put("crawl", new CrawlCommand(view)); + commands.put("exit", new ExitCommand(view)); + } + + public void handle(String input) { + if (input.isEmpty()) return; + String[] parts = input.split("\\s+"); + String cmdName = parts[0].toLowerCase(); + + Command cmd = commands.get(cmdName); + if (cmd == null) { + view.printError("Unknown command: " + cmdName); // 错误信息也走View! + return; + } + cmd.execute(parts, articles); + } +} +``` + +#### 4.6.5 main方法:组装 + +```java +// src/main/java/com/crawler/App.java +package com.crawler; + +import com.crawler.controller.CrawlerController; +import com.crawler.model.Article; +import com.crawler.view.ConsoleView; +import java.util.ArrayList; +import java.util.List; + +public class App { + public static void main(String[] args) { + ConsoleView view = new ConsoleView(); + List
articles = new ArrayList<>(); + CrawlerController controller = new CrawlerController(view, articles); + + view.printSuccess("Welcome to CLI Crawler!"); + view.printInfo("Type 'help' for commands."); + + while (true) { + controller.handle(view.readLine()); + } + } +} +``` + +#### 4.6.6 架构反思与展望:共享List
的隐患(关键!) + +**教师口播**: +> "现在这个架构已经可用了。但请大家审视一下:我们所有的Command都直接拿到了`List
`的引用。换句话说,任何一个命令都可以随意增、删、改这个列表。" +> +> "这就好像一家酒店,所有服务员、厨师、清洁工都能随意进出保险箱——**数据结构完全裸奔了**。" + +**提问**: +- "如果CrawlCommand不小心写错了代码,把一个null塞进articles,HelpCommand会不会受影响?" +- "如果未来我们要在添加文章时也写入日志文件,现在的设计能优雅实现吗?还是得在所有Command里分别加日志代码?" + +**预告解决方案**: +> "下周,我们将引入**策略模式**和一个真正的**Model仓库层(ArticleRepository)**。这个仓库会把`List`封装起来,对外只提供`add()`、`getAll()`等安全接口。任何命令想修改数据,都必须通过仓库。这就是从'数据结构'到'模型层'的进化——我们W9先搭骨架,W10给它装上盔甲。" + +--- + +### 4.7 实践任务(5分钟) + +**任务要求**: +1. 使用Maven模板创建项目 +2. 实现完整包结构(model/view/command/controller) +3. 实现4个Command:help/list/crawl/exit +4. `list`命令能展示已抓取的文章 +5. 运行并测试循环 +6. **代码找茬(额外加分)**:找出你自己代码中是否存在`System.out`直接调用、硬编码ANSI字符串等"越权行为" + +**验收标准**: +- [x] Maven编译通过 +- [x] Command接口和4个实现分离在不同文件 +- [x] Controller里没有switch-case +- [x] 新增命令只需新建类,不改Controller +- [x] list命令能正确显示空列表 +- [x] 所有输出均通过ConsoleView完成,无直接System.out.println(main除外) +- [x] ANSI颜色码集中定义为View常量 + +--- + +## 五、课后作业 + +### 5.1 必做任务 + +1. **完善Article**:增加`author`、`publishDate`字段 +2. **★ HistoryCommand(强制作业)**: + - 实现`history`命令,记录用户输入过的所有命令 + - 使用`List`存储历史(复习W8集合) + - 示例输出: + ``` + crawler> history + 1. help + 2. list + 3. crawl https://example.com + ``` +3. **AI架构审计**:将类名和方法名发给AI,指令: + > "作为Java架构审计师,请检查我的MVC三层划分是否存在越权行为?Model层是否包含输入输出代码?View层是否越权写了业务逻辑?有没有地方直接使用了System.out或硬编码ANSI码?" + +### 5.2 选做任务 + +1. **命令别名**:给`crawl`增加别名`c`,`help`增加别名`h` +2. **URL验证**:检查URL格式是否以http://或https://开头 +3. **暗色主题**:实现不同的配色方案(利用View中的ANSI常量,只需修改一处即可) +4. **思考并回答**:分析`List
`共享引用的潜在风险,写一段200字的小结 + +### 5.3 思考题 + +1. **Command vs switch-case**:增加10个命令,哪种方式代码改动量更小? +2. **如果不用Command接口,直接用Map存命令类行不行?** 接口的意义是什么? +3. **Controller里的`commands.put()`能否减少?** 提示:思考"注册机制" +4. **为什么ExitCommand里的`view.printSuccess("Bye!")`比直接`System.out.println`更"MVC"?** 提示:回忆View的职责 + +--- + +## 六、AI协同升级 + +### 架构审计师任务(必做) + +**学生执行步骤**: +1. 列出项目中所有类名(不含方法实现) +2. 将类名列表发给AI +3. 输入指令: + > "作为Java架构审计师,请检查我的MVC三层划分是否清晰。Model层是否包含了不应该有的代码(Scanner/System.out)?View层是否越权写了业务逻辑?请指出任何一处直接使用System.out.println的地方,并建议如何改正。" + +**预期AI输出**: +- 指出哪一层有越权行为 +- 建议如何整改 +- 评价整体架构健康度 + +### 进阶AI探究(选做) + +> "假设我的Command接口中execute方法接收了一个`List
`参数,请分析这种设计在工程上有什么隐患,并给出重构建议。" + +--- + +## 七、教学反思与调整记录 + +| 日期 | 事项 | 调整内容 | +|------|------|----------| +| 2026-04-28 | 首次编写 | 基于CLI+MVC重构 | +| 2026-04-30 | 教授反馈 | 引入Command模式、提供Maven模板、升级AI协同比 | +| 2026-04-30 | 逻辑重排 | 按"问题→选择→架构→模式"顺序重写 | +| 2026-05-01 | v2 vs V3合并 | 融合深度改进:增加教育哲学、批判性思考、ANSI常量、共享List隐患、故意埋坑 | + +--- + +## 附录1:Maven模板说明 + +> 老师提供`my-crawler-template.zip`压缩包,包含: +> - pom.xml(含Jsoup依赖) +> - 空的src/main/java结构 +> - .gitignore + +## 附录2:常见问题速查 + +| 问题 | 解答 | +|------|------| +| IDEA不识别pom.xml | 右键 pom.xml → Maven → Reload Project | +| 中文乱码 | Settings → Editor → File Encodings → UTF-8 | +| 包名大小写 | 包名全小写,类名首字母大写 | +| Command找不到 | 检查是否 implements Command,是否 @Override getName() | +| 命令不生效 | 检查 commands.put() 是否注册了该命令 | +| 输出颜色乱码 | IDEA控制台需支持ANSI,Windows下建议使用Windows Terminal或调整设置 | +| 我的System.out为什么被老师说越权 | View层才是与用户交互的唯一出口,所有输出都应通过View,这样将来改成GUI或日志时只需改View | + +## 附录3:教学逻辑说明 + +| 顺序 | 内容 | 设计理由 | +|------|------|----------| +| 1 | 痛点引入 | 从问题出发,让学生感受"为什么需要架构" | +| 2 | CLI vs GUI | 解释技术选型,建立"工程思维 > 视觉装饰"的认知 | +| 3 | MVC分层 | 核心架构概念,理解职责分离,通过类比及批判加深理解 | +| 4 | Command模式 | 具体实现方式,解决"肥控制器"问题 | +| 5 | Maven | 工具链支持 | +| 6 | 代码落地 | 实践验证,刻意植入细节规范,训练工程洁癖 | +| 7 | 架构反思 | 暴露共享可变状态隐患,为W10策略模式+仓库层做铺垫 | +| 8 | 实践任务 | 现场编码验证 | +| 9 | 总结 | 强化认知,预告下周 | + +--- + +## 版本说明 + +- **v1**:首次编写,CLI+MVC基础框架 +- **v2**:按"问题→选择→架构→模式"逻辑重排 +- **v3 (本版)**:融合v2结构 + V3深度改进,包含: + - 更深的CLI教育哲学 + - 餐厅类比批判性思考 + - ANSI常量集中管理工程细节 + - 全部输出走View + - 共享List架构隐患反思 + - 故意埋坑让学生找茬 + - W10铺垫(策略模式+仓库层) \ No newline at end of file diff --git a/w10/java-cli/target/maven-archiver/pom.properties b/w10/java-cli/target/maven-archiver/pom.properties new file mode 100644 index 0000000..08a8f9f --- /dev/null +++ b/w10/java-cli/target/maven-archiver/pom.properties @@ -0,0 +1,5 @@ +#Generated by Maven +#Thu Apr 30 11:50:54 CST 2026 +artifactId=datacollect-cli +groupId=com.example +version=0.1.0 diff --git a/w10/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/w10/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..e69de29 diff --git a/w10/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/w10/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..1ff8781 --- /dev/null +++ b/w10/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,15 @@ +K:\teach-space\java-cli\src\main\java\com\example\datacollect\command\CrawlCommand.java +K:\teach-space\java-cli\src\main\java\com\example\datacollect\model\Article.java +K:\teach-space\java-cli\src\main\java\com\example\datacollect\command\HelpCommand.java +K:\teach-space\java-cli\src\main\java\com\example\datacollect\Main.java +K:\teach-space\java-cli\src\main\java\com\example\datacollect\view\ConsoleView.java +K:\teach-space\java-cli\src\main\java\com\example\datacollect\strategy\BlogStrategy.java +K:\teach-space\java-cli\src\main\java\com\example\datacollect\strategy\StrategyFactory.java +K:\teach-space\java-cli\src\main\java\com\example\datacollect\repository\ArticleRepository.java +K:\teach-space\java-cli\src\main\java\com\example\datacollect\strategy\CrawlStrategy.java +K:\teach-space\java-cli\src\main\java\com\example\datacollect\strategy\HnuNewsStrategy.java +K:\teach-space\java-cli\src\main\java\com\example\datacollect\strategy\NewsStrategy.java +K:\teach-space\java-cli\src\main\java\com\example\datacollect\controller\CrawlerController.java +K:\teach-space\java-cli\src\main\java\com\example\datacollect\command\ListCommand.java +K:\teach-space\java-cli\src\main\java\com\example\datacollect\command\ExitCommand.java +K:\teach-space\java-cli\src\main\java\com\example\datacollect\command\Command.java diff --git a/w10/java-cli/target/w9-ppt.md b/w10/java-cli/target/w9-ppt.md new file mode 100644 index 0000000..5ddd5ad --- /dev/null +++ b/w10/java-cli/target/w9-ppt.md @@ -0,0 +1,530 @@ +## 高级程序设计 · 第9周 + +#### 工程架构:从"写代码"到"造系统" + +##### CLI + MVC + Command模式实战 + +--- + +### 📌 本周导航 + +- 痛点引入:脚本的宿命 +- CLI vs GUI:为什么选命令行? +- MVC分层:职责分离的艺术 +- Command模式:可扩展的路由 +- Maven模板:工程化第一步 +- 代码落地:从接口到实现 +- 架构反思:共享数据的隐患 +- 实践任务 + 课后作业 + +--- + +### 1️⃣ 痛点引入:从脚本到工程的鸿沟 + +#### 这是一段“意大利面”爬虫 + +```java +public class Crawler { + public static void main(String[] args) { + System.out.print("请输入URL: "); + Scanner scanner = new Scanner(System.in); + String url = scanner.nextLine(); + List titles = new ArrayList(); + try { + Document doc = Jsoup.connect(url).get(); + Elements elements = doc.select(".post-title"); + for (Element e : elements) { + String title = e.text(); + System.out.println("标题: " + title); + titles.add(title); + } + } catch (Exception ex) { + System.out.println("出错啦: " + ex.getMessage()); + } + } +} +``` + +--- + +### 脚本的三大痛点 + +| 需求 | 需要改哪里? | +|------|--------------| +| 保存标题到文件 | 改 main 内部逻辑 | +| 支持不同网站结构 | 全部重写解析代码 | +| 彩色输出 | 一个一个改 print | + +> 😫 **牵一发而动全身 → 改起来疼** + +### 本周目标:**让代码“改起来不疼”** + +--- + +## 2️⃣ CLI vs GUI:架构选择的思考 + +### 图形界面 vs 命令行 + +| 维度 | GUI (JavaFX) | CLI (命令行) | +|------|--------------|-------------| +| 学习重心 | 布局、控件、事件 | **架构、分层、路由** | +| 后端能力 | 弱 | 模拟真实服务器 | +| 工程思维 | 弱(关注视觉) | **强(关注逻辑)** | +| 可测试性 | 难 | 易 | + +--- + +## 核心观点 + +> **CLI 更需要 MVC!** + +- GUI 有现成事件系统,框架强塞给你一套架构 +- CLI 只有字符流 → **没有架构,分分钟写成脚本** + +> 🎯 **当外部约束消失,内部的工程纪律才真正开始建立** + +### CLI 也能很酷 + +- ANSI 彩色输出 +- 表格展示数据 +- 模拟大数据/后端开发 + +--- + +## 3️⃣ MVC 分层设计 + +### MVC 的起源与演进 + +| 年代 | 场景 | MVC的角色 | +|------|------|----------| +| 1970s | Smalltalk-72 GUI | 最早的用户界面架构 | +| 1990s | Web开发 (Struts) | 后端模板引擎 | +| 2000s | ASP.NET MVC | 现代Web框架 | +| 2020s | CLI + API | 解耦业务逻辑与表现层 | + +**核心不变:职责分离** + +--- + +## MVC 三层职责 + +![[mvc.png]] +``` +┌─────────────────────────────────────────┐ +│ 入口 │ +│ (main方法) │ +└─────────────────┬───────────────────────┘ + ▼ +┌─────────────────────────────────────────┐ +│ Controller │ +│ 只管"派给谁",不管"怎么做" │ +└─────────┬───────────────┬───────────────┘ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Model │ │ View │ +│ 管"数据" │ │ 管"呈现" │ +│ + 业务逻辑 │ │ + 输入输出 │ +└─────────────────┘ └─────────────────┘ +``` + +--- + +## 三层“禁止做什么” + +| 层级 | 禁止行为 | +| -------------- | -------------------------------------- | +| **Model** | 不能有 `System.out.println`,不能有 `Scanner` | +| **View** | 不能写爬虫逻辑,只做“传声筒” | +| **Controller** | 不能直接写业务细节,委托给 Command | + +> 🔴 **越权就是架构腐败的开始** + +--- + +## 🍽️ 餐厅类比(帮助理解) + +- **Model = 后厨**:只管做菜,不管谁来吃、怎么端 +- **View = 服务员**:只管端菜和收钱,不管菜怎么做 +- **Controller = 前台**:接单 → 派给后厨 → 叫服务员上菜 + +--- + +## 🤔 对类比的批判性思考(关键!) + +> 任何类比都有边界,不要当成真理 + +| 场景 | 暴露的问题 | +|------|------------| +| 客人有忌口(不吃香菜) | 信息需要传到后厨 → Model 可能需要知道 meta 信息 | +| 服务员反馈“今天的菜咸了” | View → Model 反向影响 | +| 后厨做完菜通知前台 | **观察者模式**,数据流可能是双向的 | + +**本课程简化模型**:请求-响应,单向流 + +--- + +## MVC 数据流向(本课程简化版) + +``` +CLI用户输入 + ↓ +View(解析命令字符串) + ↓ +Controller(找到对应Command) + ↓ +Command.execute()(执行业务逻辑) + ↓ +Model(Article数据,暂存于List) + ↓ +View(display()展示数据) + ↓ +CLI终端显示 +``` + +--- + +## 4️⃣ Command 模式:可扩展的命令路由 + +### 为什么需要 Command 模式? + +```java +switch (cmd) { + case "crawl": handleCrawl(); break; + case "help": showHelp(); break; + // 如果要增加 list 命令? + // 1. 加 case "list" + // 2. 加 handleList() 方法 + // 3. 可能还要改其他地方... +} +``` + +> 每加一个功能,就要在这个类里戳一个洞 → **肥控制器陷阱** + +--- + +## Command 模式的四个要素 + +| 要素 | 角色 | 示例 | +|------|------|------| +| Command接口 | 抽象的“订单” | `Command` | +| ConcreteCommand | 具体的订单 | `HelpCommand` | +| Invoker | 接单的前台 | `CrawlerController` | +| Receiver | 执行者 | `ConsoleView`、`ArticleRepository` | + +--- + +## Command 接口定义 + +```java +package com.crawler.command; + +import com.crawler.model.Article; +import java.util.List; + +public interface Command { + String getName(); + void execute(String[] args, List
articles); +} +``` + +--- + +## Controller 的变革:从 switch 到 Map + +```java +public class CrawlerController { + private Map commands = new HashMap<>(); + + public CrawlerController(ConsoleView view, List
articles) { + commands.put("help", new HelpCommand(view)); + commands.put("list", new ListCommand(view)); + commands.put("crawl", new CrawlCommand(view)); + commands.put("exit", new ExitCommand(view)); + } + + public void handle(String input) { + // 解析命令 → 从 Map 取 Command → 调用 execute + } +} +``` + +> **增加新命令:只需新建类,Controller 零改动!** + +--- + +## 对比:switch-case vs Command + +| 维度 | switch-case | Command模式 | +|------|-------------|-------------| +| 增加命令 | 要改 Controller | 新建一个类 | +| 多态体验 | 无 | `execute()` 多态 | +| 可测试性 | 难 | 每个 Command 单独测试 | +| 代码量 | 少 | 多,但更清晰 | + +> 🏨 **类比:酒店客房服务,前台只负责派单** + +--- + +## 5️⃣ Maven 模板与环境(5分钟) + +### 直接使用模板,不折腾配置 + +``` +my-crawler-template.zip + ↓ 解压 + IDEA打开 + ↓ 右键 pom.xml → Maven → Reload Project + ↓ 运行 App.java +``` + +### 标准目录结构 + +``` +src/main/java/com/crawler/ +├── model/Article.java +├── view/ConsoleView.java +├── command/ +│ ├── Command.java +│ ├── CrawlCommand.java +│ ├── HelpCommand.java +│ ├── ListCommand.java +│ └── ExitCommand.java +└── controller/CrawlerController.java +``` + +--- + +## 6️⃣ 代码落地(分步实现) + +### Model:Article 实体 + +```java +public class Article { + private String title; + private String url; + private String content; + // 构造器、getter/setter、toString +} +``` + +> 📦 只存放数据,没有任何输入输出代码 + +--- + +## View:ConsoleView(ANSI常量集中管理) + +```java +public class ConsoleView { + private static final String ANSI_GREEN = "\033[32m"; + private static final String ANSI_RED = "\033[31m"; + // ... 其他常量 + + public void printSuccess(String msg) { + System.out.println(ANSI_GREEN + msg + ANSI_RESET); + } + public void printError(String msg) { ... } + public void display(List
articles) { ... } +} +``` + +> ✨ **所有颜色码集中定义 → 改主题只需改一处** + +--- + +## Command 实现示例(HelpCommand) + +```java +public class HelpCommand implements Command { + private ConsoleView view; + public HelpCommand(ConsoleView v) { this.view = v; } + public String getName() { return "help"; } + public void execute(String[] args, List
articles) { + view.printInfo("Commands: crawl , list, help, exit"); + } +} +``` + +> ⚠️ 全部输出通过 `view`,绝不让 `System.out` 直接出现在这里 + +--- + +## CrawlCommand(存根,下周填坑) + +```java +public class CrawlCommand implements Command { + private ConsoleView view; + public CrawlCommand(ConsoleView v) { this.view = v; } + public String getName() { return "crawl"; } + public void execute(String[] args, List
articles) { + if (args.length < 2) { + view.printError("Usage: crawl "); + return; + } + view.printInfo("Stub: Would crawl " + args[1]); + } +} +``` + +> 🔍 **找茬点**:这里拼接字符串算是“业务逻辑”吗?留给大家用 AI 审计。 + +--- + +## ExitCommand + +```java +public class ExitCommand implements Command { + private ConsoleView view; + public ExitCommand(ConsoleView v) { this.view = v; } + public String getName() { return "exit"; } + public void execute(String[] args, List
articles) { + view.printSuccess("Bye!"); + System.exit(0); + } +} +``` + +> ✅ 所有输出都通过 View → 将来改 GUI 只需换 View 实现 + +--- + +## Controller + main 组装 + +```java +// Controller 中持有 Map +// App.java 中: +ConsoleView view = new ConsoleView(); +List
articles = new ArrayList<>(); +CrawlerController controller = new CrawlerController(view, articles); +view.printSuccess("Welcome to CLI Crawler!"); +while (true) { + controller.handle(view.readLine()); +} +``` + +> 🔁 完成交互循环 + +--- + +## 7️⃣ 架构反思:共享 List
的隐患 + +### 当前问题 + +- 所有 Command 都直接拿到 `List
` 引用 +- 任何一个命令都可以随意增、删、改列表 +- 数据完全“裸奔” + +> 🚨 就像酒店所有员工都能进保险箱 + +--- + +## 提问 + +- 如果 `CrawlCommand` 不小心把 `null` 塞进列表,`ListCommand` 会怎样? +- 如果我们要在添加文章时写日志,现在的设计能优雅实现吗? + +### 预告解决方案(W10) + +- **策略模式** + **仓库层(ArticleRepository)** +- 封装 `List`,对外只暴露 `add()`、`getAll()` 等安全接口 + +> W9 搭骨架,W10 装上盔甲 + +--- + +## 8️⃣ 实践任务(现场5分钟) + +### 必做项 + +1. 使用 Maven 模板创建项目 +2. 实现完整包结构(model/view/command/controller) +3. 实现 4 个 Command:help / list / crawl / exit +4. `list` 能展示已抓取的文章(目前存根即可) +5. 运行并测试循环 + +### 额外加分:代码找茬 + +- 检查是否仍有 `System.out` 直接调用 +- 检查 ANSI 码是否硬编码在多个地方 + +--- + +## 验收标准 + +- [x] Maven 编译通过 +- [x] Command 接口和 4 个实现在不同文件 +- [x] Controller 里没有 switch-case +- [x] 新增命令只需新建类,不改 Controller +- [x] list 能正确显示空列表 +- [x] 所有输出均通过 `ConsoleView` +- [x] ANSI 颜色码集中定义为常量 + +--- + +## 9️⃣ 课后作业 + +### 必做 + +1. **完善 Article**:增加 `author`、`publishDate` 字段 +2. **★ HistoryCommand**:记录用户输入过的所有命令(用 `List`) +3. **AI 架构审计**:将类名发给 AI,指令: + > “作为Java架构审计师,请检查我的MVC三层划分是否存在越权行为?” + +### 选做 + +- 命令别名(c 代替 crawl) +- URL 格式验证 +- 暗色主题(修改一处常量) +- 思考题:分析 `List
` 共享引用的风险(200字小结) + +--- + +## 🤖 AI 协同升级 + +### 架构审计师任务(必做) + +**步骤**: +1. 列出所有类名(不含方法实现) +2. 发给 AI +3. 指令:“检查 MVC 分层是否清晰,是否有越权行为” + +### 进阶探究(选做) + +> “假设我的 Command 接口中 execute 方法接收了一个 `List
` 参数,请分析这种设计在工程上有什么隐患,并给出重构建议。” + +--- + +## 📚 总结与过渡 + +### 本周成果 + +- ✅ 工程化包结构 +- ✅ MVC 分层清晰 +- ✅ Command 模式实现可扩展路由 +- ✅ 所有输出走 View,常量集中管理 + +### 下周预告 + +- **策略模式**:封装爬取算法 +- **仓库层(Repository)**:武装 `List
`,解决共享隐患 + +> 🚀 从“写代码”到“造系统”,踏出坚实第一步! + +--- + +## Q&A + +### 常见问题 + +| 问题 | 解答 | +|------|------| +| IDEA 不识别 pom.xml | 右键 → Maven → Reload Project | +| 中文乱码 | Settings → File Encodings → UTF-8 | +| 输出颜色乱码 | Windows 建议使用 Windows Terminal | +| 我的 System.out 被批评 | View 才是唯一输出出口 | + +--- + +## 谢谢! + +### 课件已上传,模板在课程群 + +**保持工程洁癖,下周见!** \ No newline at end of file diff --git a/w10/java-cli/第10周——设计模式:灵活性与可扩展性.md b/w10/java-cli/第10周——设计模式:灵活性与可扩展性.md new file mode 100644 index 0000000..9641102 --- /dev/null +++ b/w10/java-cli/第10周——设计模式:灵活性与可扩展性.md @@ -0,0 +1,705 @@ +# 教案:《高级程序设计》第10周——设计模式:灵活性与可扩展性 + +| 项目 | 内容 | +| -------- | ---------------------------------------------------------------------------- | +| **课程名称** | 高级程序设计 | +| **周次** | 第10周 | +| **主题** | 设计模式——灵活性与可扩展性 | +| **学时** | 2学时(90分钟) | +| **授课对象** | 已完成第9周CLI+MVC架构学习,具备Command模式基础 | +| **教学环境** | JDK 17+、IntelliJ IDEA、Maven | +| **前情提要** | W9搭建了CLI骨架:MVC分层 + Command路由,但留下了两大隐患——解析逻辑耦合在Command中、List\共享引用裸奔 | + +--- + +## 教学调整说明:为什么W10要在“骨架”上装“盔甲”? + +> **W9成果**:一个可扩展的命令行骨架 → **W9痛点**:解析器与数据存储仍在“裸奔” + +| 维度 | W9状态 | W10目标 | +|------|--------|---------| +| **架构** | MVC分层清晰 | MVC + 策略模式 + 仓库层 | +| **命令扩展** | 新增命令不改Controller | 新增解析器不改任何旧代码 | +| **数据安全** | List\全员可写 | Repository封装,只暴露安全接口 | +| **解析逻辑** | 硬编码在CrawlCommand内 | 策略模式,按URL自动匹配 | +| **代码量** | ~8个类 | ~12个类,但每个更小更纯粹 | + +**决策理由**: +1. W9学生已经感受到Command模式的好处——**多态带来的扩展性** +2. 策略模式是多态思想的又一次实战,是**接口抽象的深化** +3. 仓库层是“封装”这一OOP核心原则的落地,补上W9留下的课 +4. 解析器工厂让学生看到**“自动匹配”**的威力——增加网站支持只需新增一个类 + +**更深层的教育价值**: +> W9教会学生“怎么把代码分开”,W10要教会学生“怎么把代码分开后还能优雅地合上”——**接口即合同,工厂即自动匹配,仓库即数据守卫**。这三句话,就是本周的全部精华。 + +--- + +## 一、教学目标 + +| 目标维度 | 具体描述 | +|----------|----------| +| **知识掌握** | 理解策略模式的定义与多态本质;掌握工厂模式的两类变体(工厂方法/简单工厂)及适用场景;理解仓库模式对数据访问的封装原理。 | +| **工程实践** | 能在爬虫项目中用策略模式封装不同网站的解析逻辑;能实现解析器工厂,根据URL自动匹配解析策略;能用Repository模式替代裸List,提供安全的数据访问接口。 | +| **思维转型** | 从“写死逻辑”转向“策略可插拔”;从“直接操作集合”转向“通过仓库存取”;理解“对扩展开放,对修改关闭”的开闭原则。 | +| **工具应用** | 利用AI审查策略模式实现是否真正解耦;让AI扮演“网站结构分析师”辅助编写具体解析策略;用AI生成Repository的安全接口建议。 | + +--- + +## 二、教学重点与难点 + +| 项目 | 内容 | 突破方法 | +|------|------|----------| +| **重点** | 策略模式的多态本质、解析器工厂的自动匹配机制、Repository对数据访问的封装 | 以“新增网站需要改什么”为切入点,展示策略模式的开闭原则达成;通过“攻击”当前List裸奔的问题,引出Repository的必然性 | +| **难点** | 理解“接口即合同”的抽象思维、工厂模式中反射/Map注册的实现、仓库层与Strategy模式的协同 | 用“插座与电器”类比接口标准;现场演示从硬编码→工厂→反射的演进路径;用时序图展示“用户→Command→Strategy→Repository”的完整调用链 | + +--- + +## 三、教学过程设计(90分钟) + +| 环节 | 时间 | 教学内容 | 师生活动 | AI协同点 | +| -------------------------- | --- | ----------------------------------------------------------------- | -------------------------------------- | --------------------------- | +| **1. W9回顾与痛点暴露** | 8' | 回顾W9成果(CLI骨架),暴露两大隐患:①CrawlCommand里解析逻辑硬编码;②List\全员可读可写 | **教师演示**:展示W9代码,用“事故场景”引发思考 | — | +| **2. 策略模式:解析器的“插头标准化”** | 18' | 策略模式定义、接口设计、多态调用、与Command模式的对比 | **类比**:插座与电器;**教师演示**:从if-else到策略模式的演进 | 让AI生成“策略模式vs switch-case”对比 | +| **3. 解析器工厂:自动匹配的魔法** | 14' | 工厂模式的两种形态(简单工厂→Map注册工厂),解析器工厂实现 | **教师演示**:先用if-else判断host,再升级为Map注册工厂 | 让AI解释工厂模式与策略模式如何协同 | +| **4. Repository模式:武装数据访问** | 12' | Repository定义、接口设计、替换List\后的影响 | **教师演示**:在原代码中把List替换为Repository,展示改动点 | 学生用AI审计Repository接口的“最小完备性” | +| **5. 整体架构串联** | 8' | 用一张时序图串联:用户→CLI→Controller→Command→Strategy→Repository→Model | **师生互动**:让学生在白板上画出调用链 | — | +| **6. 代码落地** | 20' | 实现CrawlStrategy接口 + 两个策略 + 解析器工厂 + ArticleRepository | **教师演示**:分步写出代码,刻意埋入“策略匹配失败”的异常处理 | 完成后用AI检查策略模式实现 | +| **7. 架构反思与W11预告** | 5' | 当前架构还有什么隐患?(异常处理不统一、日志缺失)→ 预告W11健壮性工程 | **师生互动**:如果解析器工厂找不到匹配策略,会发生什么? | — | +| **8. 实践任务** | 5' | 实现策略模式和仓库层,完成本周代码升级 | 学生现场编码,教师巡视 | — | + +--- + +## 四、核心教学内容脚本 + +### 4.1 W9回顾与痛点暴露(8分钟) + +**教师口播**: +> "上节课我们搭了一个很漂亮的骨架——CLI+MVC+Command模式。我们先来表扬一下自己:新增一个命令,只要新建一个类,Controller零改动。但请大家想一个问题——" + +**投影展示W9的CrawlCommand存根**: +```java +public class CrawlCommand implements Command { + // ... + public void execute(String[] args, List
articles) { + if (args.length < 2) { + view.printError("Usage: crawl "); + return; + } + view.printInfo("Stub: Would crawl " + args[1]); + } +} +``` + +**提问引导**: +1. "这个存根下周要填坑了。假设我们现在要真正实现爬取,代码写在哪?" +2. "如果我要支持两个网站——比如一个技术博客和一个新闻网站——它们的HTML结构完全不一样,这个`execute`方法会变成什么样?" + +**展示“噩梦版”CrawlCommand**: +```java +public void execute(String[] args, List
articles) { + String url = args[1]; + // 五十行if-else地狱... + if (url.contains("blog.example.com")) { + // 解析技术博客的HTML + Document doc = Jsoup.connect(url).get(); + Elements titles = doc.select(".post-title"); + for (Element e : titles) { + articles.add(new Article(e.text(), url, "")); + } + } else if (url.contains("news.example.com")) { + // 解析新闻网站的HTML + Document doc = Jsoup.connect(url).get(); + Elements items = doc.select(".article-headline"); + for (Element e : items) { + articles.add(new Article(e.text(), url, "")); + } + } else { + view.printError("Unsupported website!"); + } +} +``` + +**痛点提炼**: +> "看到了吗?每支持一个新网站,就要在这里加一个`else if`。这就是W1我们痛批的'牵一发而动全身',只不过这次灾难地点从`main`搬到了`CrawlCommand`。" +> +> "更重要的是,我们上节课辛辛苦苦实现了Command模式,难道解析逻辑又要回到if-else地狱吗?**这就是W10要解决的第一个问题:怎么让解析逻辑也可插拔?**" + +**第二个隐患——共享状态的回顾**: +> "还有一件事,我们上节课结束前提到的:`List
articles`在所有Command之间共享。任何一个Command都可以往里面塞东西、删东西、甚至清空。这是W10要解决的第二个问题:**怎么给数据装上'防盗门'?**" + +--- + +### 4.2 策略模式:解析器的“插头标准化”(18分钟) + +#### 4.2.1 从类比切入 + +**教师口播**: +> "先讲个生活场景。你家里墙上有一个三孔插座,你可以插电视、插电脑、插手机充电器——任何符合这个标准的电器都能用。插座不在乎你是什么电器,它只认接口标准。" + +**类比映射**: + +| 生活场景 | 代码对应 | +|----------|----------| +| 三孔插座 | `CrawlStrategy` 接口 | +| 电视/电脑充电器 | 具体解析策略(BlogStrategy/NewsStrategy) | +| 电流 | 输入:URL + Document;输出:List\ | +| 你(使用者) | CrawlCommand | +| 插座面板 | 解析器工厂 | + +> "策略模式的核心思想就是:**定义一个算法接口,让具体的算法实现可以互相替换,而使用算法的客户端不受影响。**" + +#### 4.2.2 策略模式定义 + +```java +// src/main/java/com/crawler/strategy/CrawlStrategy.java +package com.crawler.strategy; + +import com.crawler.model.Article; +import org.jsoup.nodes.Document; +import java.util.List; + +public interface CrawlStrategy { + /** + * 从已获取的Document中解析文章列表 + * @param url 原始请求URL(用于填充Article) + * @param doc Jsoup解析后的Document + * @return 解析出的文章列表 + */ + List
parse(String url, Document doc); + + /** + * 判断此策略是否为给定URL服务 + * @param url 待判断的URL + * @return true表示此策略可以处理该URL + */ + boolean supports(String url); +} +``` + +**教师口播**: +> "注意,策略接口里有两个方法。`parse`是干活的那个,`supports`是'我能不能干这个活'——这是什么?**这是合同!** 任何网站想被我们爬虫支持,就必须签署这份合同:告诉我你是不是我的客户(supports),以及怎么解析你(parse)。" + +#### 4.2.3 具体策略实现示例 + +```java +// BlogStrategy.java - 技术博客解析策略 +public class BlogStrategy implements CrawlStrategy { + @Override + public boolean supports(String url) { + return url.contains("blog.example.com"); + } + + @Override + public List
parse(String url, Document doc) { + List
articles = new ArrayList<>(); + Elements titles = doc.select(".post-title"); + for (Element e : titles) { + articles.add(new Article(e.text(), url, "")); + } + return articles; + } +} + +// NewsStrategy.java - 新闻网站解析策略 +public class NewsStrategy implements CrawlStrategy { + @Override + public boolean supports(String url) { + return url.contains("news.example.com"); + } + + @Override + public List
parse(String url, Document doc) { + List
articles = new ArrayList<>(); + Elements items = doc.select(".article-headline"); + for (Element e : items) { + articles.add(new Article(e.text(), url, "")); + } + return articles; + } +} +``` + +**对比:策略模式 vs 硬编码if-else** + +| 维度 | if-else屎山 | 策略模式 | +|------|-------------|----------| +| 新增网站 | 改CrawlCommand,加else if | 新写一个类,实现CrawlStrategy | +| 修改解析逻辑 | 在CrawlCommand里翻找对应的else if | 只改对应策略类 | +| 测试 | 必须启动整个爬虫 | 单独对Strategy做单元测试 | +| 是否符合开闭原则 | ❌ 对修改开放 | ✅ 对扩展开放,对修改关闭 | + +**与Command模式的对比(加深理解)**: +> "上节课Command模式,我们为每个命令定义一个类;这节课策略模式,我们为每个网站的解析算法定义一个类。**本质上都是同一个OOP思想:用多态替代条件分支。** 只不过Command的接口是`execute()`,Strategy的接口是`parse()`。" +> +> "这张图你们可以记下来:**接口是消除if-else的利器,多态是接口的灵魂。**" + +--- + +### 4.3 解析器工厂:自动匹配的魔法(14分钟) + +#### 4.3.1 问题引出 + +**教师口播**: +> "现在我们有A网站的策略、B网站的策略。问题来了:谁来选策略?谁来遍历所有策略,找到一个supports返回true的?" +> +> "如果把这个逻辑写在CrawlCommand里,那策略模式就白用了——CrawlCommand还是得'知道'有哪些策略。我们要的是一个黑盒子:**把URL丢进去,自动弹出一个合适的解析器。**" + +#### 4.3.2 解析器工厂的实现 + +```java +// src/main/java/com/crawler/strategy/StrategyFactory.java +package com.crawler.strategy; + +import java.util.ArrayList; +import java.util.List; + +public class StrategyFactory { + private final List strategies = new ArrayList<>(); + + // 注册策略——新的网站只需在这里加一行 + public StrategyFactory() { + strategies.add(new BlogStrategy()); + strategies.add(new NewsStrategy()); + // 未来增加新网站:strategies.add(new XxxStrategy()); + } + + /** + * 根据URL自动匹配解析策略 + * @param url 目标URL + * @return 匹配的策略,如果没有匹配返回null + */ + public CrawlStrategy getStrategy(String url) { + for (CrawlStrategy s : strategies) { + if (s.supports(url)) { + return s; + } + } + return null; // 未找到匹配策略 + } +} +``` + +**教师口播**: +> "这个工厂类足够简单:一个List存所有策略,一个方法遍历找到匹配的。但简单不等于不强大。** +> +> **关键点**:新增网站支持,只需要——" +1. 写一个`XxxStrategy`实现`CrawlStrategy` +2. 在工厂构造器里加一行`strategies.add(new XxxStrategy())` +> +> "CrawlCommand一行不改。这就是开闭原则的胜利。" + +#### 4.3.3 从简单工厂到更高级的注册机制(拓展思维) + +**教师口播**: +> "有同学可能会问:还要在工厂构造器里加一行,能不能做到完全零改动?当然可以——用反射或者SPI。" + +**演示概念(不要求实现)**: +```java +// 进阶思路:扫描指定包下的所有CrawlStrategy实现类 +// 用反射自动注册,真正做到“新增类即生效” +// 这是Spring框架的核心思想之一 +``` + +> "这个技术我们暂时不要求掌握,但我希望你们知道:你现在写的每一个`new XxxStrategy()`,在未来都可能进化为框架级别的自动装配。**你现在建立的思维习惯,决定了你未来能走多高。**" + +#### 4.3.4 重构后的CrawlCommand + +```java +public class CrawlCommand implements Command { + private ConsoleView view; + private StrategyFactory strategyFactory; + private ArticleRepository repository; // 注意:这里是Repository了! + + public CrawlCommand(ConsoleView v, StrategyFactory f, ArticleRepository r) { + this.view = v; + this.strategyFactory = f; + this.repository = r; + } + + public String getName() { return "crawl"; } + + public void execute(String[] args, List
articles) { + if (args.length < 2) { + view.printError("Usage: crawl "); + return; + } + String url = args[1]; + + // 1. 工厂自动选策略 + CrawlStrategy strategy = strategyFactory.getStrategy(url); + if (strategy == null) { + view.printError("No strategy found for: " + url); + return; + } + + // 2. 抓取页面 + view.printInfo("Crawling: " + url); + try { + Document doc = Jsoup.connect(url).get(); + List
parsed = strategy.parse(url, doc); + + // 3. 通过仓库存入(而不是直接操作List) + for (Article a : parsed) { + repository.add(a); + } + view.printSuccess("Crawled " + parsed.size() + " articles."); + } catch (IOException e) { + view.printError("Failed to crawl: " + e.getMessage()); + } + } +} +``` + +**教师口播**: +> "注意这个CrawlCommand现在的职责:拿到URL → 交给工厂选策略 → 执行解析 → 交给仓库存储。**它自己在干什么?在调度!** 这就是上节课我们讲的Controller的'调度思维',现在向Command内部延伸了。" + +--- + +### 4.4 Repository模式:武装数据访问(12分钟) + +#### 4.4.1 问题重提 + +**教师口播**: +> "回到上节课结束时的那个问题:`List
`在所有Command之间共享。任何一个Command都可以做这些事——" +```java +articles.clear(); // 清空所有文章 +articles.add(null); // 塞入null +articles.remove(0); // 随意删除 +``` + +> "如果一个新同事接手开发,他不知道'不要动这个List'的潜规则,写了一个`articles.clear()`,你的`list`命令就突然什么都不显示了。**靠代码约定维护的秩序,早晚会被打破。我们需要实体的'规则'——代码层面的约束。**" + +#### 4.4.2 ArticleRepository的定义 + +```java +// src/main/java/com/crawler/repository/ArticleRepository.java +package com.crawler.repository; + +import com.crawler.model.Article; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class ArticleRepository { + private final List
articles = new ArrayList<>(); + + /** + * 添加一篇文章。注意:不接受null,这是代码层面的规则,不是口头约定。 + */ + public void add(Article article) { + if (article == null) { + throw new IllegalArgumentException("Article cannot be null"); + } + articles.add(article); + } + + /** + * 获取所有文章的只读视图 + * 调用者无法通过此返回值修改内部数据 + */ + public List
getAll() { + return Collections.unmodifiableList(articles); + } + + /** + * 获取文章数量 + */ + public int size() { + return articles.size(); + } + + /** + * 清空(仅管理员可调——下一篇:权限控制) + */ + public void clear() { + articles.clear(); + } +} +``` + +**教师口播**: +> "三个关键设计点——" +> +> - **add()拒绝null**:规则写在代码里,不是写在邮件里 +> - **getAll()返回不可修改的视图**:`Collections.unmodifiableList()`——调用者如果尝试add/remove,会**直接抛异常**,不是'悄悄的bug' +> - **ClearCommand要清空数据?调`repository.clear()`**,而不是直接操作List +> +> "这就是面向对象的第一课——封装。把数据藏起来,只暴露安全的方法。从'直接操作集合'到'通过仓库存取',是程序员成熟度的分水岭。" + +#### 4.4.3 仓库引入后的架构变化 + +**Command接口的execute方法调整**: + +```java +// 调整前(W9) +public interface Command { + String getName(); + void execute(String[] args, List
articles); +} + +// 调整后(W10) +public interface Command { + String getName(); + void execute(String[] args, ArticleRepository repository); +} +``` + +**教师口播**: +> "这个改动很小——把`List
`换成`ArticleRepository`。但语义完全不同:之前是'给你数据随便玩',现在是'给你一个安全的存取通道'。" + +**所有Command同步调整**: + +```java +// ListCommand.java - 调整后 +public class ListCommand implements Command { + private ConsoleView view; + public ListCommand(ConsoleView v) { this.view = v; } + public String getName() { return "list"; } + public void execute(String[] args, ArticleRepository repository) { + view.display(repository.getAll()); // 通过仓库获取数据 + } +} + +// ClearCommand.java(新增示例) +public class ClearCommand implements Command { + private ConsoleView view; + public ClearCommand(ConsoleView v) { this.view = v; } + public String getName() { return "clear"; } + public void execute(String[] args, ArticleRepository repository) { + repository.clear(); + view.printSuccess("All articles cleared."); + } +} +``` + +**Controller和main的调整**: + +```java +// App.java - 调整后 +public class App { + public static void main(String[] args) { + ConsoleView view = new ConsoleView(); + ArticleRepository repository = new ArticleRepository(); // 替代 List
+ StrategyFactory factory = new StrategyFactory(); // 新增 + + CrawlerController controller = new CrawlerController(view, repository, factory); + + view.printSuccess("Welcome to CLI Crawler v2.0!"); + view.printInfo("Type 'help' for commands."); + + while (true) { + controller.handle(view.readLine()); + } + } +} +``` + +--- + +### 4.5 整体架构串联(8分钟) + +**教师口播**: +> "现在我们把所有部件串起来,看看一个`crawl https://blog.example.com`命令走过的完整路径。" + +**时序图(口述配白板绘制)**: +``` +用户输入 "crawl https://blog.example.com" + │ + ▼ +ConsoleView.readLine() + │ + ▼ +CrawlerController.handle("crawl https://blog.example.com") + │ Map查找 "crawl" → CrawlCommand + ▼ +CrawlCommand.execute(args, repository) + │ + ├─► StrategyFactory.getStrategy(url) + │ │ 遍历List + │ │ BlogStrategy.supports(url) → true! + │ ▼ + │ 返回 BlogStrategy + │ + ├─► Jsoup.connect(url).get() → Document + │ + ├─► BlogStrategy.parse(url, doc) → List
+ │ + └─► for each article: repository.add(article) + │ + ▼ + ArticleRepository.articles.add(article) + +最终:ConsoleView.printSuccess("Crawled N articles.") +``` + +**教师口播**: +> "七步调用,每一步职责清晰:View负责输入输出,Controller负责路由,Command负责调度,Factory负责匹配,Strategy负责解析,Repository负责存储。**没有哪个类干了两个人的活,也没有哪个类不知道自己的活是什么。**" +> +> "这就是工程化——不是把代码写得快,是把代码写得对。" + +--- + +### 4.6 代码落地(20分钟) + +**教师准备**:课前准备一份“W9升级到W10”的改动清单,现场演示关键改动。 + +**改动清单**: +1. 新建`strategy/`包,创建`CrawlStrategy`接口 +2. 新建`strategy/BlogStrategy.java` +3. 新建`strategy/NewsStrategy.java` +4. 新建`strategy/StrategyFactory.java` +5. 新建`repository/`包,创建`ArticleRepository.java` +6. 修改`Command`接口的`execute`签名 +7. 修改`CrawlCommand`,引入`StrategyFactory`和`ArticleRepository` +8. 修改其余所有`Command`实现类 +9. 修改`CrawlerController`构造器 +10. 修改`App.java` + +**教师演示关键步骤**(重点演示): +- `ArticleRepository`的`Collections.unmodifiableList()` +- `StrategyFactory`的遍历匹配逻辑 +- `CrawlCommand`重写后的调度结构 + +**刻意埋入的“找茬点”**: +> "我在`StrategyFactory.getStrategy()`里,如果没有匹配的策略就返回`null`。然后在`CrawlCommand`里检查null。这其实叫'null object pattern的前奏'——如果我不想让Command检查null,我应该怎么改工厂?大家带着这个问题用AI探究。" + +--- + +### 4.7 架构反思与W11预告(5分钟) + +**教师口播**: +> "现在我们的架构比W9强壮多了:解析逻辑可插拔,数据访问有守卫。但还有一些漏洞——" + +**逐一点破**: +1. **异常处理**:`CrawlCommand`用了一个笼统的`catch (IOException e)`,如果解析过程中抛出其他异常怎么办? +2. **网络超时**:如果目标网站3秒没响应,当前代码会一直等吗? +3. **日志缺失**:所有的成功/失败信息只输出到终端,如果程序半夜跑,第二天想看昨晚抓了多少——看不了。 +4. **重试机制**:如果一次失败就直接报错,要不要给个重试的机会? + +**W11预告**: +> "下周,我们会做三件事:**自定义异常体系**、**工程化日志框架**、**防御式编程与重试机制**。W9搭骨架,W10装盔甲,W11要让这个系统**经得起现实的毒打**。" + +--- + +### 4.8 实践任务(5分钟) + +**任务要求**: +1. 从W9代码出发,完成W10升级 +2. 实现至少两个`CrawlStrategy`(可以是模拟的,不要求真实爬取) +3. 实现`StrategyFactory`和`ArticleRepository` +4. 确保所有Command通过Repository访问数据 +5. 运行并测试完整流程 + +**验收标准**: +- [x] 新增策略类只需新建文件+工厂注册一行,其余代码零改动 +- [x] `ArticleRepository`的`getAll()`返回不可修改视图 +- [x] `CrawlCommand`不包含任何网站特定的解析逻辑 +- [x] `StrategyFactory`能根据URL自动匹配正确的策略 +- [x] 所有Command的`execute`方法签名已更新为`ArticleRepository` +- [x] 无任何地方直接操作`List
` + +--- + +## 五、课后作业 + +### 5.1 必做任务 + +1. **完善ArticleRepository**:增加`addAll(List
)`批量添加方法,注意防御null +2. **★ AnalyzeCommand(集大成作业)**: + - 实现`analyze `命令 + - 内部调用`StrategyFactory`匹配策略 + - 调用策略解析文章后,**不存到Repository**,而是分析统计信息: + - 文章总数 + - 标题平均长度 + - 按某种规则排名的Top 5 + - 结果只输出,不存储 + - **提示**:这就是策略的复用——同一个解析策略,既能为`crawl`服务(存入仓库),也能为`analyze`服务(仅分析) + +3. **AI架构审计**:将完整代码的类图(或类名与方法签名列表)发给AI,指令: + > "作为Java架构审计师,请检查:①策略模式的实现是否正确解耦(CrawlCommand是否仍然包含网站特定逻辑);②Repository是否真正封装了数据访问(是否存在绕过Repository直接操作List的地方);③工厂的匹配逻辑是否存在性能隐患。请给出具体的改进建议。" + +### 5.2 选做任务 + +1. **正则策略匹配**:将`Supports()`的判断从`url.contains()`改为正则表达式,让一张策略可以匹配一类URL +2. **默认策略(DefaultStrategy)**:当没有策略匹配时,提供一个通用的“标题提取”逻辑 +3. **策略优先级**:给每个策略加一个`priority`字段,工厂按优先级匹配(而不是按注册顺序) +4. **思考并回答(200字)**: + > "策略模式中,策略的`supports()`方法有可能让两个策略都返回true,这时该选哪个?`StrategyFactory`的遍历顺序会如何影响结果?你有什么解决方案?" + +### 5.3 思考题 + +1. **Repository与List的区别是什么?** 如果Repository只是包了一层List,为什么还要用? +2. **策略工厂的演进**:如果网站数量增加到100个,逐个注册的写法还合适吗?你想到什么解决方案? +3. **`Collections.unmodifiableList()`返回的是什么?** 它真的“不可修改”吗?如果原List被修改,这个不可修改视图会怎样? + +--- + +## 六、AI协同升级 + +### 架构审计师任务(必做) + +**学生执行步骤**: +1. 画出当前项目的类依赖图(手绘或工具生成) +2. 将类名和依赖关系发给AI +3. 输入指令: + > "作为Java架构审计师,请检查这个爬虫项目的架构。重点关注:①策略模式是否真正实现了开闭原则(增加新网站是否真的只需新增类);②Repository封装是否完整(是否有绕过Repository的路径);③是否存在循环依赖。请逐一指出问题并给出改进建议。" + +**预期AI输出**: +- 指出是否还存在“改一处影响多处”的耦合 +- 判断Repository的API设计是否完备 +- 评价整体架构的开闭原则达成度 + +### 进阶AI探究(选做) + +> "假设我有一个CrawlStrategy接口和10个实现类。不用工厂模式,直接用一个Map存起来,key是策略名称。这和StrategyFactory设计有什么本质区别?各自的优缺点是什么?" + +--- + +## 七、教学反思与调整记录 + +| 日期 | 事项 | 调整内容 | +|------|------|----------| +| 2026-05-01 | 首次编写 | 基于W9骨架,引入策略模式+工厂+Repository | +| 2026-05-07 | 结构优化 | 调整策略模式与工厂的讲解顺序,先策略后工厂更自然 | + +--- + +## 附录1:W9到W10改动对照表 + +| 改动项 | W9代码 | W10代码 | +|--------|--------|---------| +| 数据存储 | `List
articles` | `ArticleRepository repository` | +| Command接口 | `execute(String[], List
)` | `execute(String[], ArticleRepository)` | +| 解析逻辑位置 | `CrawlCommand`内部 | 各`CrawlStrategy`实现类 | +| URL匹配 | 无(硬编码) | `StrategyFactory.getStrategy(url)` | +| 数据添加 | `articles.add(article)` | `repository.add(article)` | +| 数据读取 | 直接遍历`articles` | `repository.getAll()` | + +## 附录2:常见问题速查 + +| 问题 | 解答 | +|------|------| +| 策略模式和Command模式有什么区别? | Command封装“动作”(做什么事),Strategy封装“算法”(怎么做)。在爬虫中:crawl是命令(动作),如何解析是策略(算法)。 | +| 工厂一定要叫Factory吗? | 不必须。但叫Factory意味着“创建对象”的职责,符合模式命名的惯例。 | +| `Collections.unmodifiableList()`有什么用? | 返回一个只读视图,调用add/remove等方法会抛`UnsupportedOperationException`。 | +| Repository和DAO有什么区别? | 在我们的上下文中可以视为同义词。严谨地说,Repository是领域驱动设计的概念,更偏向“集合语义”;DAO更偏数据库操作。 | +| 策略的`supports()`返回true但解析失败怎么办? | 那是策略实现的bug,该策略应修复。Factory不负责验证策略的正确性。 | + +## 附录3:教学逻辑说明 + +| 顺序 | 内容 | 设计理由 | +|------|------|----------| +| 1 | W9回顾+痛点暴露 | 承上启下,从已知问题引出新知识 | +| 2 | 策略模式 | 解决解析逻辑耦合问题,深化多态理解 | +| 3 | 解析器工厂 | 解决策略选择问题,引入工厂模式 | +| 4 | Repository模式 | 解决数据安全问题,实践封装原则 | +| 5 | 架构串联 | 将所有部件统一,形成完整心智模型 | +| 6 | 代码落地 | 实践验证,从“听懂”到“会做” | +| 7 | 架构反思+预告 | 暴露新问题,为W11健壮性工程铺垫 | + +--- + +## 版本说明 + +- **v1(本版)**:基于W9教案模式首次编写,包含策略模式、工厂模式、Repository模式的完整引入 \ No newline at end of file diff --git a/w10/思考题.docx b/w10/思考题.docx new file mode 100644 index 0000000000000000000000000000000000000000..bc75933d201cd898b21623b76a8752a7a23125f4 GIT binary patch literal 11382 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 Q_@>66czd3%X?}wK4+z8PRsaA1 literal 0 HcmV?d00001 diff --git a/w11/w11/java-cli/.gitignore b/w11/w11/java-cli/.gitignore new file mode 100644 index 0000000..0ebcf1a --- /dev/null +++ b/w11/w11/java-cli/.gitignore @@ -0,0 +1,4 @@ +*.jar +*.jar +*.class +*.log \ No newline at end of file diff --git a/w11/w11/java-cli/W10 PPT.md b/w11/w11/java-cli/W10 PPT.md new file mode 100644 index 0000000..d4ba310 --- /dev/null +++ b/w11/w11/java-cli/W10 PPT.md @@ -0,0 +1,492 @@ +--- +id: "24" +title: w10-设计模式 +slug: w10-design-patterns +status: draft +view_count: 0 +created_at: 2026-05-07T12:00:00+08:00 +updated_at: 2026-05-07T14:00:00.000000000+08:00 +--- + +# 高级程序设计 · 第10周 + +### 设计模式:灵活性与可扩展性 + +### 策略模式 + 工厂 + Repository 实战 + +--- + +### 📌 本周导航 + +- W9回顾:骨架的成就与隐患 +- 策略模式:解析器的“插头标准” +- 解析器工厂:自动匹配的魔法 +- Repository:武装数据访问 +- 整体架构串联:调用链全程 +- 代码落地 + 实践任务 +- 架构反思 + W11 预告 + +--- + +## 1️⃣ W9回顾:骨架的成就与隐患 + +### 我们建了一座漂亮的房子 + +- ✅ MVC 分层清晰 +- ✅ Command 模式:**新增命令,Controller 零改动** +- ✅ 所有输出走 `ConsoleView` +- ✅ 工程包结构标准 + +--- + +### 但问题也随之而来 + +```java +// CrawlCommand 里解析逻辑怎么办? +if (url.contains("blog.example.com")) { + // 博客解析... +} else if (url.contains("news.example.com")) { + // 新闻解析... +} else { + view.printError("Unsupported website!"); +} +``` + +> 😫 每支持一个新网站,就要加一个 `else if` + +--- + +### 还有另一个“裸奔”的数据 + +```java +List
articles = new ArrayList<>(); +// 所有 Command 都可以: +articles.clear(); +articles.add(null); +articles.remove(0); +``` + +> 🚨 数据没有任何保护,靠口头约定是靠不住的 + +--- + +### 本周任务 + +1. **解析逻辑可插拔** → 策略模式 + 工厂 +2. **数据访问加守卫** → Repository 模式 + +> W9 搭骨架,W10 装盔甲 + +--- + +## 2️⃣ 策略模式:解析器的“插头标准” + +### 墙上的插座,为什么什么电器都能插? + +- **三孔插座** 是标准接口 +- 电视、电脑、手机充电器都实现这个接口 +- 插座不关心你是什么电器 + +--- + +### 爬虫的世界也一样 + +- `CrawlStrategy` = 插座接口 +- `BlogStrategy`、`NewsStrategy` = 具体电器 +- `CrawlCommand` = 使用电器的人 +- `StrategyFactory` = 插座面板 + +--- + +### 接口即合同 + +```java +public interface CrawlStrategy { + List
parse(String url, Document doc); + boolean supports(String url); +} +``` + +- `supports()`:我能不能处理这个 URL? +- `parse()`:怎么解析? +- **任何网站想被爬,签这份合同!** + +--- + +### 策略 vs 硬编码 + +| 维度 | if-else 屎山 | 策略模式 | +|------|-------------|----------| +| 新增网站 | 改 Command | 新建策略类 | +| 修改解析 | 翻找 else if | 只改对应类 | +| 测试 | 启动整个爬虫 | 单独测策略 | +| 开闭原则 | ❌ 修改开放 | ✅ 扩展开放,修改关闭 | + +--- + +### 具体策略示例 + +```java +public class BlogStrategy implements CrawlStrategy { + public boolean supports(String url) { + return url.contains("blog.example.com"); + } + public List
parse(String url, Document doc) { + List
articles = new ArrayList<>(); + for (Element e : doc.select(".post-title")) { + articles.add(new Article(e.text(), url, "")); + } + return articles; + } +} +``` + +> ✨ 一个新网站,一个独立类,各扫门前雪 + +--- + +## 3️⃣ 解析器工厂:自动匹配的魔法 + +### 谁来选择策略? + +- 如果 `CrawlCommand` 遍历所有策略 → 策略模式白用了 +- 我们需要一个黑盒子:**丢入 URL,返回合适的解析器** + +--- + +### 工厂登场 + +```java +public class StrategyFactory { + private final List strategies = new ArrayList<>(); + + public StrategyFactory() { + strategies.add(new BlogStrategy()); + strategies.add(new NewsStrategy()); + } + + public CrawlStrategy getStrategy(String url) { + for (CrawlStrategy s : strategies) { + if (s.supports(url)) return s; + } + return null; + } +} +``` + +> 🔧 新增网站只需:新建策略类 + 工厂里注册一行 + +--- + +### 开闭原则的胜利 + +- ✅ `CrawlCommand` 完全不改 +- ✅ 新增 `XxxStrategy` 和一行注册 +- ✅ 所有策略的调用方式完全一致 + +> 这就是 **“对扩展开放,对修改关闭”** + +--- + +### 重构后的 CrawlCommand + +```java +public void execute(String[] args, ArticleRepository repository) { + String url = args[1]; + CrawlStrategy strategy = strategyFactory.getStrategy(url); + if (strategy == null) { + view.printError("No strategy for: " + url); + return; + } + Document doc = Jsoup.connect(url).get(); + List
parsed = strategy.parse(url, doc); + for (Article a : parsed) { + repository.add(a); + } + view.printSuccess("Crawled " + parsed.size() + " articles."); +} +``` + +> 🧠 CrawlCommand 现在只做 **“调度”**,不做解析 + +--- + +## 4️⃣ Repository:武装数据访问 + +### 共享 List 的问题 + +```java +articles.clear(); // 清空 +articles.add(null); // 塞 null +articles.remove(0); // 随意删除 +``` + +> 靠约定维护的秩序,终将被打破 + +--- + +### 给数据装上防盗门 + +```java +public class ArticleRepository { + private final List
articles = new ArrayList<>(); + + public void add(Article article) { + if (article == null) throw new IllegalArgumentException(...); + articles.add(article); + } + + public List
getAll() { + return Collections.unmodifiableList(articles); + } + + public int size() { return articles.size(); } + + public void clear() { articles.clear(); } +} +``` + +--- + +### 三道防线 + +| 机制 | 作用 | +|------|------| +| **add 拒绝 null** | 规则写在代码里,不靠口头约定 | +| **getAll 返回不可变视图** | 任何修改立即抛异常 | +| **必须通过 repository 访问** | 封装内部结构,只暴露安全方法 | + +--- + +### 所有 Command 签名改变 + +```java +// W9 +public void execute(String[] args, List
articles); + +// W10 +public void execute(String[] args, ArticleRepository repository); +``` + +> 语义变化:从“给你数据随便玩” → “给你安全的存取通道” + +--- + +## 5️⃣ 整体架构串联 + +### 一个 `crawl` 命令的完整旅程 + +``` +用户输入 "crawl https://blog.example.com" + ↓ +ConsoleView 解析 + ↓ +Controller 路由 → CrawlCommand + ↓ +StrategyFactory.getStrategy(url) → BlogStrategy + ↓ +Jsoup 抓取 → Document + ↓ +BlogStrategy.parse(url, doc) → List
+ ↓ +Repository.add() 存储 + ↓ +ConsoleView 输出成功信息 +``` + +--- + +### 架构全景图 + +![mvc-strategy-repo](/api/v1/attachments/8 "width=70% center") + +```mermaid +flowchart TD + User(["👤 用户输入
crawl https://blog.example.com"]) --> View + + subgraph View["🎨 View 层 (ConsoleView)"] + ReadLine["readLine()"] + Display["display() / printSuccess()"] + end + + ReadLine --> Controller + + subgraph Controller["🧭 Controller 层"] + Router["CrawlerController
Map 路由"] + end + + Router --> Command + + subgraph Command["⚡ Command 层"] + CrawlCmd["CrawlCommand
(调度者)"] + end + + CrawlCmd --> Factory + + subgraph Strategy["🧩 Strategy 层"] + Factory["StrategyFactory
(自动匹配)"] + StrategyI["<> CrawlStrategy"] + BlogS["BlogStrategy"] + NewsS["NewsStrategy"] + Factory --> StrategyI --> BlogS + StrategyI --> NewsS + end + + BlogS --> Repository + + subgraph Repository["🔐 Repository 层"] + Repo["ArticleRepository
(add / getAll)"] + RepoList["List
(私有)"] + Repo --> RepoList + end + + RepoList --> Model + + subgraph Model["📦 Model 层"] + Article["Article"] + end + + CrawlCmd --> Display + Repository --> Display +``` + +> 🗺️ 每一层都有清晰的职责,每一处扩展都只需要新增而不是修改 + +--- + +## 6️⃣ 代码落地(分步升级) + +### 从 W9 升级到 W10 的改动清单 + +1. 新建 `strategy/` 包 → `CrawlStrategy` 接口 +2. 实现 `BlogStrategy`、`NewsStrategy` +3. 实现 `StrategyFactory` +4. 新建 `repository/` 包 → `ArticleRepository` +5. 修改 `Command` 接口签名 +6. 重写 `CrawlCommand` +7. 调整其他所有 `Command` +8. 调整 `Controller` 和 `App.java` + +--- + +### 关键代码演示 + +- `Collections.unmodifiableList()` 的用法 +- `StrategyFactory.getStrategy()` 的遍历逻辑 +- `CrawlCommand` 从“写死解析”到“调度组装” + +```java +// 一个改动示例 +for (Article a : parsed) { + repository.add(a); // 旧: articles.add(a); +} +``` + +--- + +### 找茬点 + +- `StrategyFactory` 没匹配到策略时返回 `null` +- `CrawlCommand` 检查 `null` 并报错 +- 有没有更优雅的方式避免 `null` 判断? + +> 🔍 课后用 AI 探索 “空对象模式” 的前奏 + +--- + +## 7️⃣ 架构反思 + 下周预告 + +### 当前架构的脆弱点 + +- ❌ 异常处理单一笼统 +- ❌ 没有重试机制 +- ❌ 网络超时无控制 +- ❌ 日志仅输出到终端 + +--- + +### W11 目标:健壮性工程 + +- ✅ **自定义异常体系**:把“出错了”变成具体的业务异常 +- ✅ **工程化日志**:记录谁、什么时间、做了什么 +- ✅ **防御式编程 + 重试机制**:网络抖动不再致命 + +> W9 搭骨架 → W10 装盔甲 → W11 让它经得起毒打 + +--- + +## 8️⃣ 实践任务(现场) + +### 必做 + +1. 基于 W9 项目升级到 W10 +2. 至少实现 2 个 CrawlStrategy(可模拟) +3. 实现 `StrategyFactory` 和 `ArticleRepository` +4. 测试完整 `crawl` → `list` 流程 + +### 验收标准 + +- [ ] 新增策略只加类+注册,零改动旧代码 +- [ ] `getAll()` 返回不可修改视图 +- [ ] `CrawlCommand` 不含网站特定解析 +- [ ] 所有 Command 用 Repository +- [ ] 无地方直接操作 `List
` + +--- + +## 9️⃣ 课后作业 + +### 必做 + +1. 完善 `ArticleRepository`:增加 `addAll`,防御 null +2. **★ AnalyzeCommand**:复用策略解析但不存储,输出统计信息 +3. **AI 架构审计**:发送类签名给 AI,检查策略解耦与封装 + +### 选做 + +- 正则策略匹配、默认策略、策略优先级 +- 思考题:两个策略都 `supports` 同一 URL 时怎么办? + +--- + +## 🤖 AI 协同升级 + +### 架构审计师(必做) + +- 画出类依赖图 +- 发给 AI:“检查开闭原则达成度,Repository 封装完备性,是否存在循环依赖” + +### 进阶探究 + +- 不用工厂,直接用 `Map` 存起来 vs `StrategyFactory` 的区别? + +--- + +## 📚 总结 + +- ✅ 策略模式:算法可插拔,新增网站零痛苦 +- ✅ 工厂:自动匹配,URL → 策略的魔法 +- ✅ Repository:数据守卫,规则从口头约定变成代码强制 +- ✅ 架构:从“分开”到“优雅合上”,对扩展开放,对修改关闭 + +### W11 预告 + +自定义异常体系 + 日志 + 重试机制 + +> 🚀 让我们造的爬虫,经得住现实的考验 + +--- + +## 谢谢! + +**保持工程洁癖,下周见!** + +--- + +# 居中标题 + +## 居中副标题 + +### 居中内容 + +--- \ No newline at end of file diff --git a/w11/w11/java-cli/pom.xml b/w11/w11/java-cli/pom.xml new file mode 100644 index 0000000..6f53b4b --- /dev/null +++ b/w11/w11/java-cli/pom.xml @@ -0,0 +1,62 @@ + + 4.0.0 + com.example + datacollect-cli + 0.1.0 + + 11 + 11 + + + + org.jsoup + jsoup + 1.17.2 + + + org.slf4j + slf4j-api + 1.7.36 + + + ch.qos.logback + logback-classic + 1.2.11 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + org.apache.maven.plugins + maven-assembly-plugin + 3.3.0 + + + + com.example.datacollect.Main + + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + + + + diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/Main.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/Main.java new file mode 100644 index 0000000..d179115 --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/Main.java @@ -0,0 +1,21 @@ +package com.example.datacollect; + +import com.example.datacollect.controller.CrawlerController; +import com.example.datacollect.repository.ArticleRepository; +import com.example.datacollect.strategy.StrategyFactory; +import com.example.datacollect.view.ConsoleView; + +public class Main { + + public static void main(String[] args) { + ConsoleView view = new ConsoleView(); + ArticleRepository repository = new ArticleRepository(); + StrategyFactory strategyFactory = new StrategyFactory(); + CrawlerController controller = new CrawlerController(view, repository, strategyFactory); + + view.printSuccess("Welcome to CLI Crawler (w10_3)! Type help for commands."); + while (true) { + controller.handle(view.readLine()); + } + } +} diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/RetryUtilsExample.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/RetryUtilsExample.java new file mode 100644 index 0000000..47fd9f9 --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/RetryUtilsExample.java @@ -0,0 +1,129 @@ +package com.example.datacollect; + +import com.example.datacollect.circuitbreaker.CircuitBreaker; +import com.example.datacollect.exception.UrlFormatException; +import com.example.datacollect.util.RetryUtils; +import com.example.datacollect.util.UrlValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.concurrent.atomic.AtomicInteger; + +public class RetryUtilsExample { + + private static final Logger logger = LoggerFactory.getLogger(RetryUtilsExample.class); + + public static void main(String[] args) { + logger.info("========== RetryUtils 使用示例 =========="); + + demonstrateSuccessfulRetry(); + demonstrateFailedRetry(); + demonstrateUrlValidation(); + demonstrateCircuitBreaker(); + + logger.info("========== 所有示例执行完成 =========="); + } + + private static void demonstrateSuccessfulRetry() { + logger.info("--- 示例1: 成功后立即返回 ---"); + AtomicInteger attemptCount = new AtomicInteger(0); + + RetryUtils.RetryResult result = RetryUtils.executeWithRetry( + () -> { + attemptCount.incrementAndGet(); + logger.info("第 {} 次尝试", attemptCount.get()); + return "操作成功!"; + }, + IOException.class + ); + + logger.info("结果: {}, 尝试次数: {}, 成功: {}", result.getResult(), result.getAttemptCount(), result.isSuccess()); + } + + private static void demonstrateFailedRetry() { + logger.info("--- 示例2: 重试3次后失败 ---"); + AtomicInteger attemptCount = new AtomicInteger(0); + + RetryUtils.RetryResult result = RetryUtils.executeWithRetry( + () -> { + int attempt = attemptCount.incrementAndGet(); + logger.info("第 {} 次尝试,模拟失败", attempt); + throw new RuntimeException("网络连接失败"); + }, + 3, + 500L, + 5000L, + IOException.class, RuntimeException.class + ); + + logger.info("结果: 成功={}, 尝试次数={}", result.isSuccess(), result.getAttemptCount()); + logger.info("异常历史:"); + for (RetryUtils.ExceptionInfo info : result.getExceptionHistory()) { + logger.info(" {}", info); + } + } + + private static void demonstrateUrlValidation() { + logger.info("--- 示例3: URL格式校验 ---"); + + String[] testUrls = { + "https://news.hnu.edu.cn", + "https://blog.example.com/articles", + "not-a-url", + "htp:/invalid", + "" + }; + + for (String url : testUrls) { + try { + UrlValidator.validate(url); + logger.info("URL 有效: {}", url); + } catch (UrlFormatException e) { + logger.warn("URL 无效: {}, 错误: {}", url, e.getMessage()); + if (e.getInvalidUrl() != null) { + logger.warn(" 不合法的URL内容: {}", e.getInvalidUrl()); + } + if (e.getCause() != null) { + logger.warn(" 根因异常: {}", e.getCause().getMessage()); + } + } + } + } + + private static void demonstrateCircuitBreaker() { + logger.info("--- 示例4: 断路器模式 ---"); + + CircuitBreaker breaker = new CircuitBreaker(3, 5000L, 2); + AtomicInteger callCount = new AtomicInteger(0); + + logger.info("初始状态: {}", breaker.getState()); + + for (int i = 1; i <= 5; i++) { + logger.info("--- 第 {} 次调用 ---", i); + try { + String result = breaker.execute(() -> { + int call = callCount.incrementAndGet(); + logger.info(" 实际执行业务逻辑, 调用次数: {}", call); + if (call % 3 == 0) { + throw new RuntimeException("服务暂时不可用"); + } + return "业务处理成功"; + }); + logger.info(" 调用成功: {}", result); + } catch (CircuitBreaker.CircuitBreakerOpenException e) { + logger.warn(" 断路器开启,快速失败: {}", e.getMessage()); + } catch (Exception e) { + logger.warn(" 调用失败: {}", e.getMessage()); + } + logger.info(" 断路器状态: {}", breaker.getState()); + } + + breaker.reset(); + logger.info("重置后断路器状态: {}", breaker.getState()); + } + + private static class IOException extends java.io.IOException { + public IOException(String message) { + super(message); + } + } +} \ No newline at end of file diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/circuitbreaker/CircuitBreaker.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/circuitbreaker/CircuitBreaker.java new file mode 100644 index 0000000..4a3c07a --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/circuitbreaker/CircuitBreaker.java @@ -0,0 +1,184 @@ +package com.example.datacollect.circuitbreaker; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +public class CircuitBreaker { + + private static final Logger logger = LoggerFactory.getLogger(CircuitBreaker.class); + + private final int failureThreshold; + private final long circuitOpenTimeoutMs; + private final int halfOpenMaxAttempts; + + private final AtomicReference state = new AtomicReference<>(State.CLOSED); + private final AtomicInteger consecutiveFailures = new AtomicInteger(0); + private final AtomicInteger consecutiveSuccesses = new AtomicInteger(0); + private final AtomicLong lastFailureTime = new AtomicLong(0); + + public CircuitBreaker(int failureThreshold, long circuitOpenTimeoutMs) { + this(failureThreshold, circuitOpenTimeoutMs, 1); + } + + public CircuitBreaker(int failureThreshold, long circuitOpenTimeoutMs, int halfOpenMaxAttempts) { + if (failureThreshold <= 0) { + throw new IllegalArgumentException("failureThreshold must be positive"); + } + if (circuitOpenTimeoutMs <= 0) { + throw new IllegalArgumentException("circuitOpenTimeoutMs must be positive"); + } + if (halfOpenMaxAttempts <= 0) { + throw new IllegalArgumentException("halfOpenMaxAttempts must be positive"); + } + this.failureThreshold = failureThreshold; + this.circuitOpenTimeoutMs = circuitOpenTimeoutMs; + this.halfOpenMaxAttempts = halfOpenMaxAttempts; + } + + public T execute(Supplier supplier) throws CircuitBreakerOpenException { + if (!allowRequest()) { + throw new CircuitBreakerOpenException( + String.format("Circuit breaker is OPEN. Failure threshold: %d, Timeout: %dms", + failureThreshold, circuitOpenTimeoutMs)); + } + + try { + T result = supplier.get(); + recordSuccess(); + return result; + } catch (Exception e) { + recordFailure(); + throw e; + } + } + + public void execute(Runnable runnable) throws CircuitBreakerOpenException { + execute(() -> { + runnable.run(); + return null; + }); + } + + private boolean allowRequest() { + State currentState = state.get(); + + switch (currentState) { + case CLOSED: + return true; + + case OPEN: + if (shouldAttemptReset()) { + transitionToHalfOpen(); + return true; + } + return false; + + case HALF_OPEN: + return true; + + default: + return false; + } + } + + private boolean shouldAttemptReset() { + long timeSinceLastFailure = System.currentTimeMillis() - lastFailureTime.get(); + return timeSinceLastFailure >= circuitOpenTimeoutMs; + } + + private void recordSuccess() { + State currentState = state.get(); + + if (currentState == State.HALF_OPEN) { + consecutiveSuccesses.incrementAndGet(); + logger.debug("Half-open success count: {}", consecutiveSuccesses.get()); + + if (consecutiveSuccesses.get() >= halfOpenMaxAttempts) { + transitionToClosed(); + } + } else if (currentState == State.CLOSED) { + consecutiveFailures.set(0); + } + } + + private void recordFailure() { + lastFailureTime.set(System.currentTimeMillis()); + consecutiveFailures.incrementAndGet(); + + State currentState = state.get(); + logger.debug("Failure recorded. Current state: {}, consecutive failures: {}", + currentState, consecutiveFailures.get()); + + if (currentState == State.HALF_OPEN) { + transitionToOpen(); + } else if (currentState == State.CLOSED && consecutiveFailures.get() >= failureThreshold) { + transitionToOpen(); + } + } + + private void transitionToOpen() { + if (state.compareAndSet(State.CLOSED, State.OPEN) || + state.compareAndSet(State.HALF_OPEN, State.OPEN)) { + logger.warn("Circuit breaker transitioned to OPEN. Failure threshold reached: {}", failureThreshold); + } + } + + private void transitionToHalfOpen() { + if (state.compareAndSet(State.OPEN, State.HALF_OPEN)) { + consecutiveSuccesses.set(0); + logger.info("Circuit breaker transitioned to HALF_OPEN. Testing service availability..."); + } + } + + private void transitionToClosed() { + if (state.compareAndSet(State.HALF_OPEN, State.CLOSED)) { + consecutiveFailures.set(0); + consecutiveSuccesses.set(0); + logger.info("Circuit breaker transitioned to CLOSED. Service recovered."); + } + } + + public State getState() { + return state.get(); + } + + public boolean isClosed() { + return state.get() == State.CLOSED; + } + + public boolean isOpen() { + return state.get() == State.OPEN; + } + + public boolean isHalfOpen() { + return state.get() == State.HALF_OPEN; + } + + public int getConsecutiveFailures() { + return consecutiveFailures.get(); + } + + public void reset() { + state.set(State.CLOSED); + consecutiveFailures.set(0); + consecutiveSuccesses.set(0); + lastFailureTime.set(0); + logger.info("Circuit breaker has been reset to CLOSED state."); + } + + public enum State { + CLOSED, + OPEN, + HALF_OPEN + } + + public static class CircuitBreakerOpenException extends RuntimeException { + public CircuitBreakerOpenException(String message) { + super(message); + } + } +} \ No newline at end of file diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/command/Command.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/command/Command.java new file mode 100644 index 0000000..029cadc --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/command/Command.java @@ -0,0 +1,8 @@ +package com.example.datacollect.command; + +import com.example.datacollect.repository.ArticleRepository; + +public interface Command { + String getName(); + void execute(String[] args, ArticleRepository repository); +} diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/command/CrawlCommand.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/command/CrawlCommand.java new file mode 100644 index 0000000..8edef26 --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/command/CrawlCommand.java @@ -0,0 +1,61 @@ +package com.example.datacollect.command; + +import com.example.datacollect.exception.UrlFormatException; +import com.example.datacollect.repository.ArticleRepository; +import com.example.datacollect.strategy.CrawlStrategy; +import com.example.datacollect.strategy.StrategyFactory; +import com.example.datacollect.util.UrlValidator; +import com.example.datacollect.view.ConsoleView; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; + +public class CrawlCommand implements Command { + private final ConsoleView view; + private final StrategyFactory strategyFactory; + + public CrawlCommand(ConsoleView view, StrategyFactory strategyFactory) { + this.view = view; + this.strategyFactory = strategyFactory; + } + + @Override + public String getName() { + return "crawl"; + } + + @Override + public void execute(String[] args, ArticleRepository repository) { + if (args.length < 2) { + view.printError("Usage: crawl "); + return; + } + String url = args[1]; + + try { + UrlValidator.validate(url); + } catch (UrlFormatException e) { + view.printError("Invalid URL: " + e.getInvalidUrl() + " - " + e.getMessage()); + return; + } + + CrawlStrategy strategy = strategyFactory.getStrategy(url); + if (strategy == null) { + view.printError("No strategy found for: " + url); + return; + } + + try { + view.printInfo("Crawling: " + url); + Document doc = Jsoup.connect(url).get(); + var articles = strategy.parse(url, doc); + for (var article : articles) { + repository.add(article); + } + view.printSuccess("Crawled " + articles.size() + " articles."); + } catch (UrlFormatException e) { + view.printError("URL format error: " + e.getInvalidUrl() + " - " + e.getMessage()); + } catch (Exception e) { + view.printError("Failed to crawl: " + e.getMessage()); + } + } +} diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/command/ExitCommand.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/command/ExitCommand.java new file mode 100644 index 0000000..eafcd1d --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/command/ExitCommand.java @@ -0,0 +1,23 @@ +package com.example.datacollect.command; + +import com.example.datacollect.repository.ArticleRepository; +import com.example.datacollect.view.ConsoleView; + +public class ExitCommand implements Command { + private final ConsoleView view; + + public ExitCommand(ConsoleView view) { + this.view = view; + } + + @Override + public String getName() { + return "exit"; + } + + @Override + public void execute(String[] args, ArticleRepository repository) { + view.printSuccess("Bye!"); + System.exit(0); + } +} diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/command/HelpCommand.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/command/HelpCommand.java new file mode 100644 index 0000000..dd7a175 --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/command/HelpCommand.java @@ -0,0 +1,22 @@ +package com.example.datacollect.command; + +import com.example.datacollect.repository.ArticleRepository; +import com.example.datacollect.view.ConsoleView; + +public class HelpCommand implements Command { + private final ConsoleView view; + + public HelpCommand(ConsoleView view) { + this.view = view; + } + + @Override + public String getName() { + return "help"; + } + + @Override + public void execute(String[] args, ArticleRepository repository) { + view.printInfo("Commands: crawl , list, help, exit"); + } +} diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/command/ListCommand.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/command/ListCommand.java new file mode 100644 index 0000000..8147be8 --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/command/ListCommand.java @@ -0,0 +1,22 @@ +package com.example.datacollect.command; + +import com.example.datacollect.repository.ArticleRepository; +import com.example.datacollect.view.ConsoleView; + +public class ListCommand implements Command { + private final ConsoleView view; + + public ListCommand(ConsoleView view) { + this.view = view; + } + + @Override + public String getName() { + return "list"; + } + + @Override + public void execute(String[] args, ArticleRepository repository) { + view.display(repository.getAll()); + } +} diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/exception/UrlFormatException.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/exception/UrlFormatException.java new file mode 100644 index 0000000..17b9a66 --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/exception/UrlFormatException.java @@ -0,0 +1,43 @@ +package com.example.datacollect.exception; + +public class UrlFormatException extends RuntimeException { + + private final String invalidUrl; + + public UrlFormatException() { + super("Invalid URL format"); + this.invalidUrl = null; + } + + public UrlFormatException(String message) { + super(message); + this.invalidUrl = null; + } + + public UrlFormatException(String message, String invalidUrl) { + super(message); + this.invalidUrl = invalidUrl; + } + + public UrlFormatException(String message, String invalidUrl, Throwable cause) { + super(message, cause); + this.invalidUrl = invalidUrl; + } + + public UrlFormatException(String message, Throwable cause) { + super(message, cause); + this.invalidUrl = null; + } + + public String getInvalidUrl() { + return invalidUrl; + } + + @Override + public String toString() { + if (invalidUrl != null) { + return String.format("UrlFormatException: %s [invalidUrl=%s]", getMessage(), invalidUrl); + } + return super.toString(); + } +} \ No newline at end of file diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/model/Article.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/model/Article.java new file mode 100644 index 0000000..147dbe6 --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/model/Article.java @@ -0,0 +1,45 @@ +package com.example.datacollect.model; + +public class Article { + private String title; + private String url; + private String content; + + public Article(String title, String url, String content) { + this.title = title; + this.url = url; + this.content = content; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + @Override + public String toString() { + return "Article{" + + "title='" + title + '\'' + + ", url='" + url + '\'' + + '}'; + } +} diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/strategy/BlogStrategy.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/strategy/BlogStrategy.java new file mode 100644 index 0000000..1e23b2b --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/strategy/BlogStrategy.java @@ -0,0 +1,25 @@ +package com.example.datacollect.strategy; + +import com.example.datacollect.model.Article; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import java.util.ArrayList; +import java.util.List; + +public class BlogStrategy implements CrawlStrategy { + @Override + public boolean supports(String url) { + return url.contains("blog.example.com"); + } + + @Override + public List
parse(String url, Document doc) { + List
articles = new ArrayList<>(); + Elements titles = doc.select(".post-title"); + for (Element e : titles) { + articles.add(new Article(e.text(), url, "")); + } + return articles; + } +} diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/strategy/HnuNewsStrategy.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/strategy/HnuNewsStrategy.java new file mode 100644 index 0000000..5ad3866 --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/strategy/HnuNewsStrategy.java @@ -0,0 +1,49 @@ +package com.example.datacollect.strategy; + +import com.example.datacollect.model.Article; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import java.util.ArrayList; +import java.util.List; + +public class HnuNewsStrategy implements CrawlStrategy { + @Override + public boolean supports(String url) { + return url.contains("news.hnu.edu.cn"); + } + + @Override + public List
parse(String url, Document doc) { + List
articles = new ArrayList<>(); + Elements listItems = doc.select("ul.list11 li"); + + for (Element li : listItems) { + Element link = li.selectFirst("a"); + if (link == null) continue; + + String articleUrl = link.attr("href"); + if (!articleUrl.startsWith("http")) { + articleUrl = "https://news.hnu.edu.cn" + articleUrl.replace("..", ""); + } + + String title = ""; + Element titleEl = link.selectFirst("h4.l2.h4s2"); + if (titleEl != null) { + title = titleEl.text().trim(); + } + + String content = ""; + Element contentEl = link.selectFirst("p.l3.ps3"); + if (contentEl != null) { + content = contentEl.text().trim(); + } + + if (!title.isEmpty()) { + articles.add(new Article(title, articleUrl, content)); + } + } + + return articles; + } +} diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/strategy/NewsStrategy.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/strategy/NewsStrategy.java new file mode 100644 index 0000000..f6eb4bd --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/strategy/NewsStrategy.java @@ -0,0 +1,25 @@ +package com.example.datacollect.strategy; + +import com.example.datacollect.model.Article; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import java.util.ArrayList; +import java.util.List; + +public class NewsStrategy implements CrawlStrategy { + @Override + public boolean supports(String url) { + return url.contains("news.example.com"); + } + + @Override + public List
parse(String url, Document doc) { + List
articles = new ArrayList<>(); + Elements items = doc.select(".article-headline"); + for (Element e : items) { + articles.add(new Article(e.text(), url, "")); + } + return articles; + } +} diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/util/RetryUtils.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/util/RetryUtils.java new file mode 100644 index 0000000..33417ba --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/util/RetryUtils.java @@ -0,0 +1,162 @@ +package com.example.datacollect.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +public final class RetryUtils { + + private static final Logger logger = LoggerFactory.getLogger(RetryUtils.class); + private static final long DEFAULT_BASE_WAIT_MS = 500L; + private static final long DEFAULT_MAX_WAIT_MS = 30000L; + private static final int DEFAULT_MAX_RETRIES = 3; + + private RetryUtils() { + } + + public static RetryResult executeWithRetry(Callable callable, Class... retryableExceptions) { + return executeWithRetry(callable, DEFAULT_MAX_RETRIES, DEFAULT_BASE_WAIT_MS, DEFAULT_MAX_WAIT_MS, retryableExceptions); + } + + public static RetryResult executeWithRetry(Callable callable, int maxRetries, long baseWaitMs, long maxWaitMs, Class... retryableExceptions) { + List exceptionHistory = new CopyOnWriteArrayList<>(); + Class[] allowedExceptions = retryableExceptions.length > 0 ? retryableExceptions : new Class[]{Exception.class}; + + for (int attempt = 0; attempt <= maxRetries; attempt++) { + try { + T result = callable.call(); + if (attempt > 0) { + logger.info("Retry succeeded on attempt {}", attempt); + } + return new RetryResult<>(result, attempt, null, exceptionHistory); + } catch (Exception e) { + exceptionHistory.add(new ExceptionInfo(attempt, e)); + + boolean isRetryable = isRetryable(e, allowedExceptions); + if (!isRetryable) { + logger.warn("Non-retryable exception on attempt {}: {}", attempt, e.getMessage()); + return new RetryResult<>(null, attempt, e, exceptionHistory); + } + + if (attempt >= maxRetries) { + logger.warn("Max retries ({}) reached. Last exception: {}", maxRetries, e.getMessage()); + return new RetryResult<>(null, attempt, e, exceptionHistory); + } + + long waitTime = calculateWaitTime(attempt, baseWaitMs, maxWaitMs); + logger.info("Attempt {} failed: {}. Waiting {} ms before retry...", attempt, e.getMessage(), waitTime); + + try { + TimeUnit.MILLISECONDS.sleep(waitTime); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return new RetryResult<>(null, attempt, new InterruptedException("Retry interrupted"), exceptionHistory); + } + } + } + return new RetryResult<>(null, maxRetries, new RuntimeException("Unexpected retry failure"), exceptionHistory); + } + + public static void executeWithRetry(Runnable runnable, Class... retryableExceptions) { + executeWithRetry(() -> { + runnable.run(); + return null; + }, retryableExceptions); + } + + public static void executeWithRetry(Runnable runnable, int maxRetries, long baseWaitMs, long maxWaitMs, Class... retryableExceptions) { + executeWithRetry(() -> { + runnable.run(); + return null; + }, maxRetries, baseWaitMs, maxWaitMs, retryableExceptions); + } + + private static long calculateWaitTime(int attempt, long baseWaitMs, long maxWaitMs) { + long waitTime = (long) (baseWaitMs * Math.pow(2, attempt)); + return Math.min(waitTime, maxWaitMs); + } + + private static boolean isRetryable(Exception e, Class[] allowedExceptions) { + for (Class allowed : allowedExceptions) { + if (allowed.isInstance(e)) { + return true; + } + } + return false; + } + + public static final class RetryResult { + private final T result; + private final int attemptCount; + private final Exception finalException; + private final List exceptionHistory; + + private RetryResult(T result, int attemptCount, Exception finalException, List exceptionHistory) { + this.result = result; + this.attemptCount = attemptCount; + this.finalException = finalException; + this.exceptionHistory = new ArrayList<>(exceptionHistory); + } + + public T getResult() { + return result; + } + + public int getAttemptCount() { + return attemptCount; + } + + public Exception getFinalException() { + return finalException; + } + + public List getExceptionHistory() { + return new ArrayList<>(exceptionHistory); + } + + public boolean isSuccess() { + return finalException == null; + } + + @Override + public String toString() { + return String.format("RetryResult{success=%s, attempts=%d, finalException=%s, historySize=%d}", + isSuccess(), attemptCount, finalException != null ? finalException.getMessage() : "none", exceptionHistory.size()); + } + } + + public static final class ExceptionInfo { + private final int attempt; + private final String message; + private final String exceptionType; + + public ExceptionInfo(int attempt, Exception e) { + this.attempt = attempt; + this.message = e.getMessage(); + this.exceptionType = e.getClass().getName(); + } + + public int getAttempt() { + return attempt; + } + + public String getMessage() { + return message; + } + + public String getExceptionType() { + return exceptionType; + } + + @Override + public String toString() { + return String.format("[Attempt %d] %s: %s", attempt, exceptionType, message); + } + } +} \ No newline at end of file diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/util/UrlValidator.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/util/UrlValidator.java new file mode 100644 index 0000000..fc753b2 --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/util/UrlValidator.java @@ -0,0 +1,49 @@ +package com.example.datacollect.util; + +import com.example.datacollect.exception.UrlFormatException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.regex.Pattern; + +public final class UrlValidator { + + private static final String URL_REGEX = "^https?://[a-zA-Z0-9][a-zA-Z0-9\\-.]*\\.[a-zA-Z]{2,}(/.*)?$"; + private static final Pattern URL_PATTERN = Pattern.compile(URL_REGEX); + + private UrlValidator() { + } + + public static void validate(String url) { + if (url == null || url.trim().isEmpty()) { + throw new UrlFormatException("URL cannot be null or empty", url); + } + + String trimmedUrl = url.trim(); + + if (!URL_PATTERN.matcher(trimmedUrl).matches()) { + throw new UrlFormatException("URL format is invalid: does not match expected pattern", trimmedUrl); + } + + try { + new URL(trimmedUrl); + } catch (MalformedURLException e) { + throw new UrlFormatException("URL is malformed and cannot be parsed", trimmedUrl, e); + } + } + + public static boolean isValid(String url) { + if (url == null || url.trim().isEmpty()) { + return false; + } + String trimmedUrl = url.trim(); + if (!URL_PATTERN.matcher(trimmedUrl).matches()) { + return false; + } + try { + new URL(trimmedUrl); + return true; + } catch (MalformedURLException e) { + return false; + } + } +} \ No newline at end of file diff --git a/w11/w11/java-cli/src/main/java/com/example/datacollect/view/ConsoleView.java b/w11/w11/java-cli/src/main/java/com/example/datacollect/view/ConsoleView.java new file mode 100644 index 0000000..3c1d47a --- /dev/null +++ b/w11/w11/java-cli/src/main/java/com/example/datacollect/view/ConsoleView.java @@ -0,0 +1,42 @@ +package com.example.datacollect.view; + +import com.example.datacollect.model.Article; +import java.util.List; +import java.util.Scanner; + +public class ConsoleView { + private static final String ANSI_RESET = "\u001B[0m"; + private static final String ANSI_GREEN = "\u001B[32m"; + private static final String ANSI_RED = "\u001B[31m"; + private static final String ANSI_BLUE = "\u001B[34m"; + + private final Scanner scanner = new Scanner(System.in); + + public String readLine() { + System.out.print("> "); + return scanner.nextLine(); + } + + public void printSuccess(String msg) { + System.out.println(ANSI_GREEN + msg + ANSI_RESET); + } + + public void printError(String msg) { + System.out.println(ANSI_RED + msg + ANSI_RESET); + } + + public void printInfo(String msg) { + System.out.println(ANSI_BLUE + msg + ANSI_RESET); + } + + public void display(List
articles) { + if (articles.isEmpty()) { + printInfo("暂无文章,请先执行 crawl。"); + return; + } + for (int i = 0; i < articles.size(); i++) { + Article a = articles.get(i); + System.out.println((i + 1) + ". " + a.getTitle() + " | " + a.getUrl()); + } + } +} diff --git a/w11/w11/java-cli/src/main/resources/logback.xml b/w11/w11/java-cli/src/main/resources/logback.xml new file mode 100644 index 0000000..3e1f548 --- /dev/null +++ b/w11/w11/java-cli/src/main/resources/logback.xml @@ -0,0 +1,63 @@ + + + + + + + + + + ${LOG_PATTERN} + UTF-8 + + + + + ${LOG_HOME}/${APP_NAME}.log + + ${LOG_PATTERN} + UTF-8 + + + + ${LOG_HOME}/${APP_NAME}-%d{yyyy-MM-dd}.%i.log + + 100MB + + 30 + 3GB + + + + + 512 + 0 + false + true + + + + + 1024 + 0 + true + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/w11/w11/java-cli/target/W9工程架构 - 教案v3.md b/w11/w11/java-cli/target/W9工程架构 - 教案v3.md new file mode 100644 index 0000000..09de868 --- /dev/null +++ b/w11/w11/java-cli/target/W9工程架构 - 教案v3.md @@ -0,0 +1,758 @@ +--- + +# 教案:《高级程序设计》第9周——工程架构:从"写代码"到"造系统" + +| 项目 | 内容 | +|------|------| +| **课程名称** | 高级程序设计 | +| **周次** | 第9周 | +| **主题** | 工程架构——从"写代码"到"造系统" | +| **学时** | 2学时(90分钟) | +| **授课对象** | 具备Python基础、已完成Java面向对象特性学习的学生 | +| **教学环境** | JDK 17+、IntelliJ IDEA、Maven(模板) | +| **前情提要** | 本课程原计划使用JavaFX GUI,后根据教学反馈转向CLI + MVC + 爬虫工程化 | + +--- + +## 教学调整说明:为什么选择CLI而不是GUI? + +> **原计划**:JavaFX桌面应用 → **新计划**:CLI命令行应用 + +| 维度 | GUI (JavaFX) | CLI (命令行) | +|------|--------------|-------------| +| **学习重心** | 布局、控件、事件监听 | 架构、分层、命令路由 | +| **学生痛点** | "窗口点击"与后端能力无关 | 真正锻炼工程思维 | +| **AI辅助** | AI生成FXML,学生看不懂 | AI辅助重构架构 | +| **工程化** | 脱离真实后端开发场景 | 模拟真实服务器/大数据开发 | +| **核心转型** | "视觉装饰"优先 | "逻辑架构"优先 | + +**决策理由**: +1. **985学生需要的是工程思维**,不是拖控件 +2. **接口抽象**是弱项,CLI + MVC更能暴露这个问题 +3. **彩色终端**足够酷炫,且代码量可控 + +**更深层的教育价值**: +> 在GUI框架中,架构已被框架强制划定,学生只是"遵守规矩";而CLI世界里没有任何框架告诉你模型在哪、视图在哪——**当外部约束消失,内部的工程纪律才真正建立**。这正是本节课要传递的核心精神。 + +--- + +## 一、教学目标 + +| 目标维度 | 具体描述 | +|----------|----------| +| **知识掌握** | 理解MVC架构的职责划分及其演化脉络;掌握Maven项目结构与pom.xml基础;理解Command模式的路由原理。 | +| **工程实践** | 能搭建规范的Maven项目包结构;能实现基于Scanner的控制台交互;能用Command接口实现可扩展的命令路由;能识别架构中的"越权行为"。 | +| **思维转型** | 从"一个类写全部"转向"分层解耦";从"修改现有代码"转向"新增类实现功能";从"满足功能"转向"代码的工程洁癖"。 | +| **工具应用** | 利用AI辅助审查MVC职责越权;让AI扮演"架构审计师"检查分层是否清晰;理解AI生成代码中的架构缺陷。 | + +--- + +## 二、教学重点与难点 + +| 项目 | 内容 | 突破方法 | +|------|------|----------| +| **重点** | MVC三层职责划分、CLI交互实现、Command接口解耦、代码中的工程细节(常量、输出归属) | 以"新增命令需要改什么"为切入点,展示Command模式的优势;通过现场"代码找茬"强化细节意识 | +| **难点** | Controller不写业务逻辑、Command接口的多态实现、共享数据模型的设计缺陷识别 | 现场演示:增加一个命令只需新建类,无需修改Controller;暴露`List
`共享引用的问题并预告解决方案 | + +--- + +## 三、教学过程设计(90分钟) + +| 环节 | 时间 | 教学内容 | 师生活动 | AI协同点 | +|------|------|----------|----------|----------| +| **1. 痛点引入:从脚本到工程的鸿沟** | 10' | 展示"意大利面"式爬虫代码,演示改一处需要动全身 | **教师演示**:现场展示一段混乱代码,让学生找问题 | 用AI分析代码耦合度 | +| **2. CLI vs GUI:架构选择的思考** | 10' | 对比两种方案的优缺点,解释为什么CLI更适合培养工程思维 | **教师讲解**:用对比表格说明选择CLI的理由 | — | +| **3. MVC分层设计** | 20' | 讲解Model/View/Controller三层职责,用"餐厅类比"强化理解,随后批判类比局限性 | **教师讲解**:配合架构图讲解三层交互,引导学生寻找类比破绽 | 用AI生成MVC职责对照表 | +| **4. Command模式:可扩展的命令路由** | 15' | 引入Command接口,解释"一个命令就是一个类" | **类比**:Command像酒店的服务部门,Controller是前台 | 让AI解释Command模式的多态原理 | +| **5. Maven模板与环境** | 5' | 直接使用提供的Maven模板,讲解目录结构 | **教师演示**:解压模板 → IDEA打开 → 运行 | — | +| **6. 三层代码落地** | 20' | **Model**:Article实体
**View**:ConsoleView(ANSI常量)
**Command接口**+实现
**Controller**:Map路由 | **教师演示**:分步写出代码,刻意埋入1~2个"越权细节"让学生找茬 | 学生用AI做"架构审计" | +| **7. 架构反思与展望** | 5' | 指出当前`List
`共享引用的问题,预告W10策略模式与仓库层 | **师生互动**:你发现这个设计有什么风险? | 让AI分析共享可变状态的危害 | +| **8. 实践任务:空壳程序** | 5' | 搭建完整包结构,实现CLI循环 | 学生现场编码,教师巡视 | 完成后用AI检查包结构 | +| **9. 总结与过渡** | 5' | 本周实现了"骨架+命令可扩展",下周填入"灵魂"——解析器,并解决数据安全问题 | 总结Command模式优势,预告策略模式 | — | + +--- + +## 四、核心教学内容脚本 + +### 4.1 痛点引入:从脚本到工程的鸿沟(10分钟) + +**教师口播**: +> "同学们,前8周我们学的是Java语法,从变量到类,从继承到接口。但有一个问题:代码写完之后,怎么组织?" +> +> "来看这段代码——这是某个同学写的'爬虫',他一个人完成了一个'完整'的项目。" + +**展示"脚本式"代码**: +```java +public class Crawler { + public static void main(String[] args) { + System.out.print("请输入URL: "); + Scanner scanner = new Scanner(System.in); + String url = scanner.nextLine(); + + List titles = new ArrayList(); + try { + Document doc = Jsoup.connect(url).get(); + Elements elements = doc.select(".post-title"); + for (Element e : elements) { + String title = e.text(); + System.out.println("标题: " + title); + titles.add(title); + } + } catch (Exception ex) { + System.out.println("出错啦: " + ex.getMessage()); + } + } +} +``` + +**提问引导**: +1. "如果我想把标题保存到文件,要改哪里?" +2. "如果我想支持另一个网站,它的HTML结构不一样,要怎么办?" +3. "如果我想让输出变成彩色,要改哪里?" + +**痛点提炼**: +> "看到了吗?才60行代码,已经'牵一发而动全身'了。这就是一个'脚本'的宿命——功能全混在一起,改一个小需求,整个文件都要翻。" +> +> "这周我们要解决:**怎么让代码'改起来不疼'?**" + +--- + +### 4.2 CLI vs GUI:架构选择的思考(10分钟) + +**教师口播**: +> "既然要写一个'完整'的爬虫应用,我们有两个选择:图形界面(GUI)或命令行界面(CLI)。为什么我推荐CLI而不是GUI?" + +**对比表格** + +| 维度 | GUI (JavaFX) | CLI (命令行) | +|------|--------------|-------------| +| **代码量** | FXML + Controller + CSS,大量模板代码 | 纯Java,代码量可控 | +| **学习重心** | 布局、控件、事件监听 | 架构、分层、命令路由 | +| **后端能力** | 几乎无关 | 模拟真实服务器开发 | +| **可测试性** | 难(需要UI测试框架) | 易(直接测试Command类) | +| **工程思维** | 弱(关注视觉) | 强(关注逻辑) | + +**核心观点**: +> **CLI更需要MVC!** GUI有现成的事件系统(点击按钮→触发事件),而CLI只有字符流。**没有架构,分分钟写成脚本**。MVC在CLI里是"刚需",不是"装饰"。 +> +> **更深一层**:在GUI里,框架已经硬塞给你一套架构,你只是在填空;但在CLI里,所有结构都必须由你亲手搭建。**当外部约束消失,内部的工程纪律才真正开始建立**——这才是本节课的真正目的。 + +**CLI也能很酷**: +- ANSI彩色输出(红/绿/黄/蓝) +- 表格展示数据 +- 进度条动画 +- 模拟真实大数据开发场景 + +--- + +### 4.3 MVC分层设计(20分钟) + +#### 4.3.1 MVC的起源与演进 + +**教师口播**: +> "MVC不是新东西,它是1970年代为桌面应用设计的架构思想。但它的核心——'职责分离'——在任何软件里都适用。" + +| 年代 | 场景 | MVC的角色 | +|------|------|----------| +| 1970s | Smalltalk-72 GUI | 最早的用户界面架构 | +| 1990s | Web开发 (Struts) | 后端模板引擎 | +| 2000s | ASP.NET MVC | 现代Web框架 | +| 2020s | CLI + API | 解耦业务逻辑与表现层 | + +#### 4.3.2 从GUI到CLI的映射 + +| GUI组件 | CLI对应 | 说明 | +|--------|--------|------| +| 窗口/按钮 | 命令行输入 | **View = 用户交互** | +| 数据模型 | Article实体类 | **Model = 数据结构** | +| 事件监听 | Command路由 | **Controller = 调度** | + +#### 4.3.3 MVC三层职责 + +**架构图示**: + +``` +┌─────────────────────────────────────────┐ +│ 入口 │ +│ (main方法) │ +└─────────────────┬───────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Controller │ +│ - 接收命令(crawl, help, exit) │ +│ - 分发给对应的Command │ +│ 【口诀】:Controller不管"怎么做", │ +│ 只管"派给谁" │ +└─────────┬───────────────┬───────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Model │ │ View │ +│ - 数据实体 │ │ - 输入解析 │ +│ - 业务逻辑 │ │ - 输出格式化 │ +│ 【口诀】: │ │ 【口诀】: │ +│ Model管"数据" │ │ View管"呈现" │ +└─────────────────┘ └─────────────────┘ +``` + +**三层职责详解** + +| 层级 | 职责 | 典型代码 | 禁止做什么 | +|------|------|----------|------------| +| **Model** | 数据结构 + 业务逻辑 | `class Article { String title; String content; }` | 不能有`System.out.println`,不能有`Scanner` | +| **View** | 接收用户输入 + 格式化输出 | `class ConsoleView { String readInput(); void print(String); }` | 不能写爬虫逻辑,只做"传声筒" | +| **Controller** | 协调调度 | `class CrawlerController { void handle(String cmd) { ... } }` | 不能直接写业务细节,委托给Command | + +#### 4.3.4 类比强化:"餐厅类比" + +> "把MVC想象成一家餐厅: +> - **Model是后厨**:只管做菜(数据加工),不管谁来吃、怎么端 +> - **View是服务员**:只管端菜和收钱(输入输出),不管菜怎么做 +> - **Controller是前台**:只管把顾客的点单传给后厨,把做好的菜端给顾客 +> +> 如果后厨开始管'谁来吃饭',这餐厅就乱了。" + +#### 4.3.5 对"餐厅类比"的批判性思考(关键!) + +**教师导引**: +> "刚才的类比好理解吗?很好。但任何一个类比都有它的边界,如果把它当成真理,就会出问题。现在我们来给这个类比'找茬'。" + +**提问学生**: +1. "后厨真的完全不知道客人是谁吗?如果客人有忌口(比如不吃香菜),这个信息需不需要传到后厨?" +2. "服务员只是端菜吗?在真实餐厅里,服务员经常向后厨反馈'客人觉得今天的菜咸了',这属于View→Model的反向影响吗?" +3. "在这个类比里,我们把前台(Controller)和后厨(Model)的关系说成单向的。但实际上,后厨做完了菜,需要通知前台'菜好了',这不就是**观察者模式**吗?" + +**点明本质**: +> "实际MVC的数据流向常常是**双向**的:Controller调用Model的方法改变数据,Model变化后又通知View更新显示。只不过在本次CLI项目中,我们暂时使用'请求-响应'的单向简化模型——用户输入命令,系统处理,然后立即输出结果。这个简化版够用,但你要知道完整的MVC是更动态的。随着系统复杂,Model层需要一个专门的'仓库类'来管理数据,并通知视图刷新——这正是W10我们将要深入的内容。" + +#### 4.3.6 MVC的数据流向(本课程简化版) + +``` +CLI用户输入 + ↓ +View(解析命令字符串) + ↓ +Controller(找到对应Command) + ↓ +Command.execute()(执行业务逻辑) + ↓ +Model(Article数据,目前暂存于List) + ↓ +View(display()展示数据) + ↓ +CLI终端显示 +``` + +--- + +### 4.4 Command模式:可扩展的命令路由(15分钟) + +**教师口播**: +> "现在引入一个设计模式——Command(命令)模式。它的核心思想是:**一个命令就是一个类**。" + +#### 4.4.1 为什么需要Command模式? + +**演示:增加一个命令的代价(switch-case版)** +```java +// 现状代码 +switch (cmd) { + case "crawl": handleCrawl(); break; + case "help": showHelp(); break; + // 如果要增加 list 命令? + // 1. 加 case "list" + // 2. 加 handleList() 方法 + // 3. 可能还要改其他地方... +} +``` + +**提问**: +- "如果我想增加10个命令,这个类要改多少次?" +- "如果我不小心删了一个case,整个程序还能跑吗?" + +**痛点提炼**: +> "每加一个功能,就要在这个类里戳一个洞。**这就是'肥控制器'陷阱**——所有的逻辑都堆在Controller里,它变成了新的'意大利面'。" + +#### 4.4.2 Command模式的四个要素 + +| 要素 | 角色 | 示例 | +|------|------|------| +| **Command接口** | 抽象的"订单" | `Command` 接口 | +| **ConcreteCommand** | 具体的订单 | `HelpCommand`、`CrawlCommand` | +| **Invoker** | 接单的前台 | `CrawlerController` | +| **Receiver** | 执行者 | `ConsoleView`、`ArticleRepository` | + +#### 4.4.3 Command接口定义 + +```java +// src/main/java/com/crawler/command/Command.java +package com.crawler.command; + +import com.crawler.model.Article; +import java.util.List; + +public interface Command { + String getName(); // 命令名,如 "crawl" + void execute(String[] args, List
articles); // 执行逻辑 +} +``` + +#### 4.4.4 Controller的变革(从switch到Map) + +```java +// 修改后的Controller +public class CrawlerController { + private Map commands; // 用Map存命令 + private ConsoleView view; // 持有View以输出错误 + + public CrawlerController(ConsoleView view, List
articles) { + this.view = view; + this.commands = new HashMap<>(); + // 增加命令无需改Controller代码,只需在这里注册 + commands.put("crawl", new CrawlCommand(view)); + commands.put("help", new HelpCommand(view)); + commands.put("list", new ListCommand(view)); + commands.put("exit", new ExitCommand(view)); + } + + public void handle(String input) { + if (input.isEmpty()) return; + String[] parts = input.split("\\s+"); + String cmd = parts[0].toLowerCase(); + + Command command = commands.get(cmd); + if (command == null) { + view.printError("Unknown command: " + cmd); // 通过View输出,而非直接System.out + return; + } + + // 执行命令,传入参数和文章列表 + command.execute(parts, articles); + } +} +``` + +**对比表格** + +| 维度 | switch-case | Command模式 | +|------|-------------|-------------| +| 增加命令 | 要改Controller | 新建一个类 | +| 多态体验 | 无 | execute()的多态调用 | +| 可测试性 | 难 | 每个Command可单独测试 | +| 代码量 | 少 | 多,但更清晰 | + +**类比强化**: +> "Command模式就像**酒店的客房服务**:每个服务(清理、送餐、按摩)都是一个独立的部门。前台(Controller)只负责接电话,然后把请求'派发'给对应的部门。部门自己知道怎么干活,不需要前台教。" +> +> "如果想新增一个服务,前台只需要'登记'一下,不需要把现有部门重新装修。" + +--- + +### 4.5 Maven模板与环境(5分钟) + +**教师口播**: +> "这周我们不发愁pom.xml配置。我已经把 Maven 模板准备好了,你们只需要解压、打开、运行。" + +**模板使用流程**: +``` +1. 解压 [my-crawler-template.zip] +2. 用 IDEA 打开文件夹 +3. 右键 pom.xml → Maven → Reload Project +4. 运行 App.java +``` + +**标准目录结构**: +``` +src/main/java/com/crawler/ +├── model/ +│ └── Article.java +├── view/ +│ └── ConsoleView.java +├── command/ +│ ├── Command.java (接口) +│ ├── CrawlCommand.java +│ ├── HelpCommand.java +│ ├── ListCommand.java +│ └── ExitCommand.java +└── controller/ + └── CrawlerController.java +``` + +--- + +### 4.6 代码落地(20分钟) + +#### 4.6.1 Model层:Article实体 + +```java +// src/main/java/com/crawler/model/Article.java +package com.crawler.model; + +public class Article { + private String title; + private String url; + private String content; + + public Article(String title, String url, String content) { + this.title = title; + this.url = url; + this.content = content; + } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getUrl() { return url; } + public void setUrl(String url) { this.url = url; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + + @Override + public String toString() { + return "Article{title='" + title + "', url='" + url + "'}"; + } +} +``` + +#### 4.6.2 View层:ANSI常量集中管理(工程细节!) + +```java +// src/main/java/com/crawler/view/ConsoleView.java +package com.crawler.view; + +import com.crawler.model.Article; +import java.util.List; +import java.util.Scanner; + +public class ConsoleView { + // ANSI颜色常量——集中管理,避免散落各处 + private static final String ANSI_GREEN = "\033[32m"; + private static final String ANSI_RED = "\033[31m"; + private static final String ANSI_CYAN = "\033[36m"; + private static final String ANSI_RESET = "\033[0m"; + + private Scanner scanner = new Scanner(System.in); + + public String readLine() { + System.out.print("crawler> "); + return scanner.nextLine().trim(); + } + + public void print(String msg) { + System.out.println(msg); + } + + public void printSuccess(String msg) { + print(ANSI_GREEN + msg + ANSI_RESET); + } + + public void printError(String msg) { + print(ANSI_RED + msg + ANSI_RESET); + } + + public void printInfo(String msg) { + print(ANSI_CYAN + msg + ANSI_RESET); + } + + // 展示文章列表 + public void display(List
articles) { + if (articles.isEmpty()) { + printInfo("No articles yet. Use 'crawl ' first."); + return; + } + print("+----------+--------------------------------+"); + print("| Title | URL |"); + print("+----------+--------------------------------+"); + for (Article a : articles) { + String title = a.getTitle(); + if (title.length() > 10) title = title.substring(0, 10) + ".."; + String url = a.getUrl(); + if (url.length() > 30) url = url.substring(0, 27) + "..."; + print("| " + String.format("%-10s", title) + " | " + url + " |"); + } + print("+----------+--------------------------------+"); + printInfo("Total: " + articles.size() + " articles"); + } +} +``` + +**教师提示**: +> "注意:所有ANSI转义码都被定义为`private static final`常量。如果把`\033[32m`散落在项目各处,一旦想调整颜色,就得满世界去改——这正是我们之前痛批的'意大利面'。**这就是工程细节**。" + +#### 4.6.3 Command接口与四个实现(全部通过View输出) + +```java +// Command.java +public interface Command { + String getName(); + void execute(String[] args, List
articles); +} + +// HelpCommand.java +public class HelpCommand implements Command { + private ConsoleView view; + public HelpCommand(ConsoleView v) { this.view = v; } + public String getName() { return "help"; } + public void execute(String[] args, List
articles) { + view.printInfo("Commands: crawl , list, help, exit"); + } +} + +// ListCommand.java +public class ListCommand implements Command { + private ConsoleView view; + public ListCommand(ConsoleView v) { this.view = v; } + public String getName() { return "list"; } + public void execute(String[] args, List
articles) { + view.display(articles); + } +} + +// CrawlCommand.java (存根) +public class CrawlCommand implements Command { + private ConsoleView view; + public CrawlCommand(ConsoleView v) { this.view = v; } + public String getName() { return "crawl"; } + public void execute(String[] args, List
articles) { + if (args.length < 2) { + view.printError("Usage: crawl "); + return; + } + view.printInfo("Stub: Would crawl " + args[1]); + } +} + +// ExitCommand.java +public class ExitCommand implements Command { + private ConsoleView view; + public ExitCommand(ConsoleView v) { this.view = v; } + public String getName() { return "exit"; } + public void execute(String[] args, List
articles) { + view.printSuccess("Bye!"); // 全部输出都通过View,绝不让System.out直接出现在这里 + System.exit(0); + } +} +``` + +**故意埋设的"找茬点"**: +> "我在刚才的代码里有没有隐藏违反MVC原则的地方?`CrawlCommand`的存根里,`view.printInfo("Stub: Would crawl " + args[1]);` —— 这个字符串拼接算是"业务逻辑"吗?留给大家用AI架构审计时讨论。 + +#### 4.6.4 Controller:Map路由(全部通过View输出) + +```java +// src/main/java/com/crawler/controller/CrawlerController.java +package com.crawler.controller; + +import com.crawler.command.*; +import com.crawler.model.Article; +import com.crawler.view.ConsoleView; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class CrawlerController { + private Map commands = new HashMap<>(); + private ConsoleView view; // 持有View + private List
articles; + + public CrawlerController(ConsoleView view, List
articles) { + this.view = view; + this.articles = articles; + commands.put("help", new HelpCommand(view)); + commands.put("list", new ListCommand(view)); + commands.put("crawl", new CrawlCommand(view)); + commands.put("exit", new ExitCommand(view)); + } + + public void handle(String input) { + if (input.isEmpty()) return; + String[] parts = input.split("\\s+"); + String cmdName = parts[0].toLowerCase(); + + Command cmd = commands.get(cmdName); + if (cmd == null) { + view.printError("Unknown command: " + cmdName); // 错误信息也走View! + return; + } + cmd.execute(parts, articles); + } +} +``` + +#### 4.6.5 main方法:组装 + +```java +// src/main/java/com/crawler/App.java +package com.crawler; + +import com.crawler.controller.CrawlerController; +import com.crawler.model.Article; +import com.crawler.view.ConsoleView; +import java.util.ArrayList; +import java.util.List; + +public class App { + public static void main(String[] args) { + ConsoleView view = new ConsoleView(); + List
articles = new ArrayList<>(); + CrawlerController controller = new CrawlerController(view, articles); + + view.printSuccess("Welcome to CLI Crawler!"); + view.printInfo("Type 'help' for commands."); + + while (true) { + controller.handle(view.readLine()); + } + } +} +``` + +#### 4.6.6 架构反思与展望:共享List
的隐患(关键!) + +**教师口播**: +> "现在这个架构已经可用了。但请大家审视一下:我们所有的Command都直接拿到了`List
`的引用。换句话说,任何一个命令都可以随意增、删、改这个列表。" +> +> "这就好像一家酒店,所有服务员、厨师、清洁工都能随意进出保险箱——**数据结构完全裸奔了**。" + +**提问**: +- "如果CrawlCommand不小心写错了代码,把一个null塞进articles,HelpCommand会不会受影响?" +- "如果未来我们要在添加文章时也写入日志文件,现在的设计能优雅实现吗?还是得在所有Command里分别加日志代码?" + +**预告解决方案**: +> "下周,我们将引入**策略模式**和一个真正的**Model仓库层(ArticleRepository)**。这个仓库会把`List`封装起来,对外只提供`add()`、`getAll()`等安全接口。任何命令想修改数据,都必须通过仓库。这就是从'数据结构'到'模型层'的进化——我们W9先搭骨架,W10给它装上盔甲。" + +--- + +### 4.7 实践任务(5分钟) + +**任务要求**: +1. 使用Maven模板创建项目 +2. 实现完整包结构(model/view/command/controller) +3. 实现4个Command:help/list/crawl/exit +4. `list`命令能展示已抓取的文章 +5. 运行并测试循环 +6. **代码找茬(额外加分)**:找出你自己代码中是否存在`System.out`直接调用、硬编码ANSI字符串等"越权行为" + +**验收标准**: +- [x] Maven编译通过 +- [x] Command接口和4个实现分离在不同文件 +- [x] Controller里没有switch-case +- [x] 新增命令只需新建类,不改Controller +- [x] list命令能正确显示空列表 +- [x] 所有输出均通过ConsoleView完成,无直接System.out.println(main除外) +- [x] ANSI颜色码集中定义为View常量 + +--- + +## 五、课后作业 + +### 5.1 必做任务 + +1. **完善Article**:增加`author`、`publishDate`字段 +2. **★ HistoryCommand(强制作业)**: + - 实现`history`命令,记录用户输入过的所有命令 + - 使用`List`存储历史(复习W8集合) + - 示例输出: + ``` + crawler> history + 1. help + 2. list + 3. crawl https://example.com + ``` +3. **AI架构审计**:将类名和方法名发给AI,指令: + > "作为Java架构审计师,请检查我的MVC三层划分是否存在越权行为?Model层是否包含输入输出代码?View层是否越权写了业务逻辑?有没有地方直接使用了System.out或硬编码ANSI码?" + +### 5.2 选做任务 + +1. **命令别名**:给`crawl`增加别名`c`,`help`增加别名`h` +2. **URL验证**:检查URL格式是否以http://或https://开头 +3. **暗色主题**:实现不同的配色方案(利用View中的ANSI常量,只需修改一处即可) +4. **思考并回答**:分析`List
`共享引用的潜在风险,写一段200字的小结 + +### 5.3 思考题 + +1. **Command vs switch-case**:增加10个命令,哪种方式代码改动量更小? +2. **如果不用Command接口,直接用Map存命令类行不行?** 接口的意义是什么? +3. **Controller里的`commands.put()`能否减少?** 提示:思考"注册机制" +4. **为什么ExitCommand里的`view.printSuccess("Bye!")`比直接`System.out.println`更"MVC"?** 提示:回忆View的职责 + +--- + +## 六、AI协同升级 + +### 架构审计师任务(必做) + +**学生执行步骤**: +1. 列出项目中所有类名(不含方法实现) +2. 将类名列表发给AI +3. 输入指令: + > "作为Java架构审计师,请检查我的MVC三层划分是否清晰。Model层是否包含了不应该有的代码(Scanner/System.out)?View层是否越权写了业务逻辑?请指出任何一处直接使用System.out.println的地方,并建议如何改正。" + +**预期AI输出**: +- 指出哪一层有越权行为 +- 建议如何整改 +- 评价整体架构健康度 + +### 进阶AI探究(选做) + +> "假设我的Command接口中execute方法接收了一个`List
`参数,请分析这种设计在工程上有什么隐患,并给出重构建议。" + +--- + +## 七、教学反思与调整记录 + +| 日期 | 事项 | 调整内容 | +|------|------|----------| +| 2026-04-28 | 首次编写 | 基于CLI+MVC重构 | +| 2026-04-30 | 教授反馈 | 引入Command模式、提供Maven模板、升级AI协同比 | +| 2026-04-30 | 逻辑重排 | 按"问题→选择→架构→模式"顺序重写 | +| 2026-05-01 | v2 vs V3合并 | 融合深度改进:增加教育哲学、批判性思考、ANSI常量、共享List隐患、故意埋坑 | + +--- + +## 附录1:Maven模板说明 + +> 老师提供`my-crawler-template.zip`压缩包,包含: +> - pom.xml(含Jsoup依赖) +> - 空的src/main/java结构 +> - .gitignore + +## 附录2:常见问题速查 + +| 问题 | 解答 | +|------|------| +| IDEA不识别pom.xml | 右键 pom.xml → Maven → Reload Project | +| 中文乱码 | Settings → Editor → File Encodings → UTF-8 | +| 包名大小写 | 包名全小写,类名首字母大写 | +| Command找不到 | 检查是否 implements Command,是否 @Override getName() | +| 命令不生效 | 检查 commands.put() 是否注册了该命令 | +| 输出颜色乱码 | IDEA控制台需支持ANSI,Windows下建议使用Windows Terminal或调整设置 | +| 我的System.out为什么被老师说越权 | View层才是与用户交互的唯一出口,所有输出都应通过View,这样将来改成GUI或日志时只需改View | + +## 附录3:教学逻辑说明 + +| 顺序 | 内容 | 设计理由 | +|------|------|----------| +| 1 | 痛点引入 | 从问题出发,让学生感受"为什么需要架构" | +| 2 | CLI vs GUI | 解释技术选型,建立"工程思维 > 视觉装饰"的认知 | +| 3 | MVC分层 | 核心架构概念,理解职责分离,通过类比及批判加深理解 | +| 4 | Command模式 | 具体实现方式,解决"肥控制器"问题 | +| 5 | Maven | 工具链支持 | +| 6 | 代码落地 | 实践验证,刻意植入细节规范,训练工程洁癖 | +| 7 | 架构反思 | 暴露共享可变状态隐患,为W10策略模式+仓库层做铺垫 | +| 8 | 实践任务 | 现场编码验证 | +| 9 | 总结 | 强化认知,预告下周 | + +--- + +## 版本说明 + +- **v1**:首次编写,CLI+MVC基础框架 +- **v2**:按"问题→选择→架构→模式"逻辑重排 +- **v3 (本版)**:融合v2结构 + V3深度改进,包含: + - 更深的CLI教育哲学 + - 餐厅类比批判性思考 + - ANSI常量集中管理工程细节 + - 全部输出走View + - 共享List架构隐患反思 + - 故意埋坑让学生找茬 + - W10铺垫(策略模式+仓库层) \ No newline at end of file diff --git a/w11/w11/java-cli/target/classes/logback.xml b/w11/w11/java-cli/target/classes/logback.xml new file mode 100644 index 0000000..3e1f548 --- /dev/null +++ b/w11/w11/java-cli/target/classes/logback.xml @@ -0,0 +1,63 @@ + + + + + + + + + + ${LOG_PATTERN} + UTF-8 + + + + + ${LOG_HOME}/${APP_NAME}.log + + ${LOG_PATTERN} + UTF-8 + + + + ${LOG_HOME}/${APP_NAME}-%d{yyyy-MM-dd}.%i.log + + 100MB + + 30 + 3GB + + + + + 512 + 0 + false + true + + + + + 1024 + 0 + true + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/w11/w11/java-cli/target/maven-archiver/pom.properties b/w11/w11/java-cli/target/maven-archiver/pom.properties new file mode 100644 index 0000000..08a8f9f --- /dev/null +++ b/w11/w11/java-cli/target/maven-archiver/pom.properties @@ -0,0 +1,5 @@ +#Generated by Maven +#Thu Apr 30 11:50:54 CST 2026 +artifactId=datacollect-cli +groupId=com.example +version=0.1.0 diff --git a/w11/w11/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/w11/w11/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..e69de29 diff --git a/w11/w11/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/w11/w11/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..cb024a4 --- /dev/null +++ b/w11/w11/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,16 @@ +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\command\HelpCommand.java +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\Main.java +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\util\UrlValidator.java +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\RetryUtilsExample.java +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\command\ExitCommand.java +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\view\ConsoleView.java +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\command\Command.java +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\util\RetryUtils.java +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\circuitbreaker\CircuitBreaker.java +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\exception\UrlFormatException.java +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\strategy\BlogStrategy.java +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\command\CrawlCommand.java +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\command\ListCommand.java +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\strategy\NewsStrategy.java +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\strategy\HnuNewsStrategy.java +E:\½ļ (2)\java\w11\w10\java-cli\src\main\java\com\example\datacollect\model\Article.java diff --git a/w11/w11/java-cli/target/w9-ppt.md b/w11/w11/java-cli/target/w9-ppt.md new file mode 100644 index 0000000..5ddd5ad --- /dev/null +++ b/w11/w11/java-cli/target/w9-ppt.md @@ -0,0 +1,530 @@ +## 高级程序设计 · 第9周 + +#### 工程架构:从"写代码"到"造系统" + +##### CLI + MVC + Command模式实战 + +--- + +### 📌 本周导航 + +- 痛点引入:脚本的宿命 +- CLI vs GUI:为什么选命令行? +- MVC分层:职责分离的艺术 +- Command模式:可扩展的路由 +- Maven模板:工程化第一步 +- 代码落地:从接口到实现 +- 架构反思:共享数据的隐患 +- 实践任务 + 课后作业 + +--- + +### 1️⃣ 痛点引入:从脚本到工程的鸿沟 + +#### 这是一段“意大利面”爬虫 + +```java +public class Crawler { + public static void main(String[] args) { + System.out.print("请输入URL: "); + Scanner scanner = new Scanner(System.in); + String url = scanner.nextLine(); + List titles = new ArrayList(); + try { + Document doc = Jsoup.connect(url).get(); + Elements elements = doc.select(".post-title"); + for (Element e : elements) { + String title = e.text(); + System.out.println("标题: " + title); + titles.add(title); + } + } catch (Exception ex) { + System.out.println("出错啦: " + ex.getMessage()); + } + } +} +``` + +--- + +### 脚本的三大痛点 + +| 需求 | 需要改哪里? | +|------|--------------| +| 保存标题到文件 | 改 main 内部逻辑 | +| 支持不同网站结构 | 全部重写解析代码 | +| 彩色输出 | 一个一个改 print | + +> 😫 **牵一发而动全身 → 改起来疼** + +### 本周目标:**让代码“改起来不疼”** + +--- + +## 2️⃣ CLI vs GUI:架构选择的思考 + +### 图形界面 vs 命令行 + +| 维度 | GUI (JavaFX) | CLI (命令行) | +|------|--------------|-------------| +| 学习重心 | 布局、控件、事件 | **架构、分层、路由** | +| 后端能力 | 弱 | 模拟真实服务器 | +| 工程思维 | 弱(关注视觉) | **强(关注逻辑)** | +| 可测试性 | 难 | 易 | + +--- + +## 核心观点 + +> **CLI 更需要 MVC!** + +- GUI 有现成事件系统,框架强塞给你一套架构 +- CLI 只有字符流 → **没有架构,分分钟写成脚本** + +> 🎯 **当外部约束消失,内部的工程纪律才真正开始建立** + +### CLI 也能很酷 + +- ANSI 彩色输出 +- 表格展示数据 +- 模拟大数据/后端开发 + +--- + +## 3️⃣ MVC 分层设计 + +### MVC 的起源与演进 + +| 年代 | 场景 | MVC的角色 | +|------|------|----------| +| 1970s | Smalltalk-72 GUI | 最早的用户界面架构 | +| 1990s | Web开发 (Struts) | 后端模板引擎 | +| 2000s | ASP.NET MVC | 现代Web框架 | +| 2020s | CLI + API | 解耦业务逻辑与表现层 | + +**核心不变:职责分离** + +--- + +## MVC 三层职责 + +![[mvc.png]] +``` +┌─────────────────────────────────────────┐ +│ 入口 │ +│ (main方法) │ +└─────────────────┬───────────────────────┘ + ▼ +┌─────────────────────────────────────────┐ +│ Controller │ +│ 只管"派给谁",不管"怎么做" │ +└─────────┬───────────────┬───────────────┘ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Model │ │ View │ +│ 管"数据" │ │ 管"呈现" │ +│ + 业务逻辑 │ │ + 输入输出 │ +└─────────────────┘ └─────────────────┘ +``` + +--- + +## 三层“禁止做什么” + +| 层级 | 禁止行为 | +| -------------- | -------------------------------------- | +| **Model** | 不能有 `System.out.println`,不能有 `Scanner` | +| **View** | 不能写爬虫逻辑,只做“传声筒” | +| **Controller** | 不能直接写业务细节,委托给 Command | + +> 🔴 **越权就是架构腐败的开始** + +--- + +## 🍽️ 餐厅类比(帮助理解) + +- **Model = 后厨**:只管做菜,不管谁来吃、怎么端 +- **View = 服务员**:只管端菜和收钱,不管菜怎么做 +- **Controller = 前台**:接单 → 派给后厨 → 叫服务员上菜 + +--- + +## 🤔 对类比的批判性思考(关键!) + +> 任何类比都有边界,不要当成真理 + +| 场景 | 暴露的问题 | +|------|------------| +| 客人有忌口(不吃香菜) | 信息需要传到后厨 → Model 可能需要知道 meta 信息 | +| 服务员反馈“今天的菜咸了” | View → Model 反向影响 | +| 后厨做完菜通知前台 | **观察者模式**,数据流可能是双向的 | + +**本课程简化模型**:请求-响应,单向流 + +--- + +## MVC 数据流向(本课程简化版) + +``` +CLI用户输入 + ↓ +View(解析命令字符串) + ↓ +Controller(找到对应Command) + ↓ +Command.execute()(执行业务逻辑) + ↓ +Model(Article数据,暂存于List) + ↓ +View(display()展示数据) + ↓ +CLI终端显示 +``` + +--- + +## 4️⃣ Command 模式:可扩展的命令路由 + +### 为什么需要 Command 模式? + +```java +switch (cmd) { + case "crawl": handleCrawl(); break; + case "help": showHelp(); break; + // 如果要增加 list 命令? + // 1. 加 case "list" + // 2. 加 handleList() 方法 + // 3. 可能还要改其他地方... +} +``` + +> 每加一个功能,就要在这个类里戳一个洞 → **肥控制器陷阱** + +--- + +## Command 模式的四个要素 + +| 要素 | 角色 | 示例 | +|------|------|------| +| Command接口 | 抽象的“订单” | `Command` | +| ConcreteCommand | 具体的订单 | `HelpCommand` | +| Invoker | 接单的前台 | `CrawlerController` | +| Receiver | 执行者 | `ConsoleView`、`ArticleRepository` | + +--- + +## Command 接口定义 + +```java +package com.crawler.command; + +import com.crawler.model.Article; +import java.util.List; + +public interface Command { + String getName(); + void execute(String[] args, List
articles); +} +``` + +--- + +## Controller 的变革:从 switch 到 Map + +```java +public class CrawlerController { + private Map commands = new HashMap<>(); + + public CrawlerController(ConsoleView view, List
articles) { + commands.put("help", new HelpCommand(view)); + commands.put("list", new ListCommand(view)); + commands.put("crawl", new CrawlCommand(view)); + commands.put("exit", new ExitCommand(view)); + } + + public void handle(String input) { + // 解析命令 → 从 Map 取 Command → 调用 execute + } +} +``` + +> **增加新命令:只需新建类,Controller 零改动!** + +--- + +## 对比:switch-case vs Command + +| 维度 | switch-case | Command模式 | +|------|-------------|-------------| +| 增加命令 | 要改 Controller | 新建一个类 | +| 多态体验 | 无 | `execute()` 多态 | +| 可测试性 | 难 | 每个 Command 单独测试 | +| 代码量 | 少 | 多,但更清晰 | + +> 🏨 **类比:酒店客房服务,前台只负责派单** + +--- + +## 5️⃣ Maven 模板与环境(5分钟) + +### 直接使用模板,不折腾配置 + +``` +my-crawler-template.zip + ↓ 解压 + IDEA打开 + ↓ 右键 pom.xml → Maven → Reload Project + ↓ 运行 App.java +``` + +### 标准目录结构 + +``` +src/main/java/com/crawler/ +├── model/Article.java +├── view/ConsoleView.java +├── command/ +│ ├── Command.java +│ ├── CrawlCommand.java +│ ├── HelpCommand.java +│ ├── ListCommand.java +│ └── ExitCommand.java +└── controller/CrawlerController.java +``` + +--- + +## 6️⃣ 代码落地(分步实现) + +### Model:Article 实体 + +```java +public class Article { + private String title; + private String url; + private String content; + // 构造器、getter/setter、toString +} +``` + +> 📦 只存放数据,没有任何输入输出代码 + +--- + +## View:ConsoleView(ANSI常量集中管理) + +```java +public class ConsoleView { + private static final String ANSI_GREEN = "\033[32m"; + private static final String ANSI_RED = "\033[31m"; + // ... 其他常量 + + public void printSuccess(String msg) { + System.out.println(ANSI_GREEN + msg + ANSI_RESET); + } + public void printError(String msg) { ... } + public void display(List
articles) { ... } +} +``` + +> ✨ **所有颜色码集中定义 → 改主题只需改一处** + +--- + +## Command 实现示例(HelpCommand) + +```java +public class HelpCommand implements Command { + private ConsoleView view; + public HelpCommand(ConsoleView v) { this.view = v; } + public String getName() { return "help"; } + public void execute(String[] args, List
articles) { + view.printInfo("Commands: crawl , list, help, exit"); + } +} +``` + +> ⚠️ 全部输出通过 `view`,绝不让 `System.out` 直接出现在这里 + +--- + +## CrawlCommand(存根,下周填坑) + +```java +public class CrawlCommand implements Command { + private ConsoleView view; + public CrawlCommand(ConsoleView v) { this.view = v; } + public String getName() { return "crawl"; } + public void execute(String[] args, List
articles) { + if (args.length < 2) { + view.printError("Usage: crawl "); + return; + } + view.printInfo("Stub: Would crawl " + args[1]); + } +} +``` + +> 🔍 **找茬点**:这里拼接字符串算是“业务逻辑”吗?留给大家用 AI 审计。 + +--- + +## ExitCommand + +```java +public class ExitCommand implements Command { + private ConsoleView view; + public ExitCommand(ConsoleView v) { this.view = v; } + public String getName() { return "exit"; } + public void execute(String[] args, List
articles) { + view.printSuccess("Bye!"); + System.exit(0); + } +} +``` + +> ✅ 所有输出都通过 View → 将来改 GUI 只需换 View 实现 + +--- + +## Controller + main 组装 + +```java +// Controller 中持有 Map +// App.java 中: +ConsoleView view = new ConsoleView(); +List
articles = new ArrayList<>(); +CrawlerController controller = new CrawlerController(view, articles); +view.printSuccess("Welcome to CLI Crawler!"); +while (true) { + controller.handle(view.readLine()); +} +``` + +> 🔁 完成交互循环 + +--- + +## 7️⃣ 架构反思:共享 List
的隐患 + +### 当前问题 + +- 所有 Command 都直接拿到 `List
` 引用 +- 任何一个命令都可以随意增、删、改列表 +- 数据完全“裸奔” + +> 🚨 就像酒店所有员工都能进保险箱 + +--- + +## 提问 + +- 如果 `CrawlCommand` 不小心把 `null` 塞进列表,`ListCommand` 会怎样? +- 如果我们要在添加文章时写日志,现在的设计能优雅实现吗? + +### 预告解决方案(W10) + +- **策略模式** + **仓库层(ArticleRepository)** +- 封装 `List`,对外只暴露 `add()`、`getAll()` 等安全接口 + +> W9 搭骨架,W10 装上盔甲 + +--- + +## 8️⃣ 实践任务(现场5分钟) + +### 必做项 + +1. 使用 Maven 模板创建项目 +2. 实现完整包结构(model/view/command/controller) +3. 实现 4 个 Command:help / list / crawl / exit +4. `list` 能展示已抓取的文章(目前存根即可) +5. 运行并测试循环 + +### 额外加分:代码找茬 + +- 检查是否仍有 `System.out` 直接调用 +- 检查 ANSI 码是否硬编码在多个地方 + +--- + +## 验收标准 + +- [x] Maven 编译通过 +- [x] Command 接口和 4 个实现在不同文件 +- [x] Controller 里没有 switch-case +- [x] 新增命令只需新建类,不改 Controller +- [x] list 能正确显示空列表 +- [x] 所有输出均通过 `ConsoleView` +- [x] ANSI 颜色码集中定义为常量 + +--- + +## 9️⃣ 课后作业 + +### 必做 + +1. **完善 Article**:增加 `author`、`publishDate` 字段 +2. **★ HistoryCommand**:记录用户输入过的所有命令(用 `List`) +3. **AI 架构审计**:将类名发给 AI,指令: + > “作为Java架构审计师,请检查我的MVC三层划分是否存在越权行为?” + +### 选做 + +- 命令别名(c 代替 crawl) +- URL 格式验证 +- 暗色主题(修改一处常量) +- 思考题:分析 `List
` 共享引用的风险(200字小结) + +--- + +## 🤖 AI 协同升级 + +### 架构审计师任务(必做) + +**步骤**: +1. 列出所有类名(不含方法实现) +2. 发给 AI +3. 指令:“检查 MVC 分层是否清晰,是否有越权行为” + +### 进阶探究(选做) + +> “假设我的 Command 接口中 execute 方法接收了一个 `List
` 参数,请分析这种设计在工程上有什么隐患,并给出重构建议。” + +--- + +## 📚 总结与过渡 + +### 本周成果 + +- ✅ 工程化包结构 +- ✅ MVC 分层清晰 +- ✅ Command 模式实现可扩展路由 +- ✅ 所有输出走 View,常量集中管理 + +### 下周预告 + +- **策略模式**:封装爬取算法 +- **仓库层(Repository)**:武装 `List
`,解决共享隐患 + +> 🚀 从“写代码”到“造系统”,踏出坚实第一步! + +--- + +## Q&A + +### 常见问题 + +| 问题 | 解答 | +|------|------| +| IDEA 不识别 pom.xml | 右键 → Maven → Reload Project | +| 中文乱码 | Settings → File Encodings → UTF-8 | +| 输出颜色乱码 | Windows 建议使用 Windows Terminal | +| 我的 System.out 被批评 | View 才是唯一输出出口 | + +--- + +## 谢谢! + +### 课件已上传,模板在课程群 + +**保持工程洁癖,下周见!** \ No newline at end of file diff --git a/w11/w11/java-cli/第10周——设计模式:灵活性与可扩展性.md b/w11/w11/java-cli/第10周——设计模式:灵活性与可扩展性.md new file mode 100644 index 0000000..9641102 --- /dev/null +++ b/w11/w11/java-cli/第10周——设计模式:灵活性与可扩展性.md @@ -0,0 +1,705 @@ +# 教案:《高级程序设计》第10周——设计模式:灵活性与可扩展性 + +| 项目 | 内容 | +| -------- | ---------------------------------------------------------------------------- | +| **课程名称** | 高级程序设计 | +| **周次** | 第10周 | +| **主题** | 设计模式——灵活性与可扩展性 | +| **学时** | 2学时(90分钟) | +| **授课对象** | 已完成第9周CLI+MVC架构学习,具备Command模式基础 | +| **教学环境** | JDK 17+、IntelliJ IDEA、Maven | +| **前情提要** | W9搭建了CLI骨架:MVC分层 + Command路由,但留下了两大隐患——解析逻辑耦合在Command中、List\共享引用裸奔 | + +--- + +## 教学调整说明:为什么W10要在“骨架”上装“盔甲”? + +> **W9成果**:一个可扩展的命令行骨架 → **W9痛点**:解析器与数据存储仍在“裸奔” + +| 维度 | W9状态 | W10目标 | +|------|--------|---------| +| **架构** | MVC分层清晰 | MVC + 策略模式 + 仓库层 | +| **命令扩展** | 新增命令不改Controller | 新增解析器不改任何旧代码 | +| **数据安全** | List\全员可写 | Repository封装,只暴露安全接口 | +| **解析逻辑** | 硬编码在CrawlCommand内 | 策略模式,按URL自动匹配 | +| **代码量** | ~8个类 | ~12个类,但每个更小更纯粹 | + +**决策理由**: +1. W9学生已经感受到Command模式的好处——**多态带来的扩展性** +2. 策略模式是多态思想的又一次实战,是**接口抽象的深化** +3. 仓库层是“封装”这一OOP核心原则的落地,补上W9留下的课 +4. 解析器工厂让学生看到**“自动匹配”**的威力——增加网站支持只需新增一个类 + +**更深层的教育价值**: +> W9教会学生“怎么把代码分开”,W10要教会学生“怎么把代码分开后还能优雅地合上”——**接口即合同,工厂即自动匹配,仓库即数据守卫**。这三句话,就是本周的全部精华。 + +--- + +## 一、教学目标 + +| 目标维度 | 具体描述 | +|----------|----------| +| **知识掌握** | 理解策略模式的定义与多态本质;掌握工厂模式的两类变体(工厂方法/简单工厂)及适用场景;理解仓库模式对数据访问的封装原理。 | +| **工程实践** | 能在爬虫项目中用策略模式封装不同网站的解析逻辑;能实现解析器工厂,根据URL自动匹配解析策略;能用Repository模式替代裸List,提供安全的数据访问接口。 | +| **思维转型** | 从“写死逻辑”转向“策略可插拔”;从“直接操作集合”转向“通过仓库存取”;理解“对扩展开放,对修改关闭”的开闭原则。 | +| **工具应用** | 利用AI审查策略模式实现是否真正解耦;让AI扮演“网站结构分析师”辅助编写具体解析策略;用AI生成Repository的安全接口建议。 | + +--- + +## 二、教学重点与难点 + +| 项目 | 内容 | 突破方法 | +|------|------|----------| +| **重点** | 策略模式的多态本质、解析器工厂的自动匹配机制、Repository对数据访问的封装 | 以“新增网站需要改什么”为切入点,展示策略模式的开闭原则达成;通过“攻击”当前List裸奔的问题,引出Repository的必然性 | +| **难点** | 理解“接口即合同”的抽象思维、工厂模式中反射/Map注册的实现、仓库层与Strategy模式的协同 | 用“插座与电器”类比接口标准;现场演示从硬编码→工厂→反射的演进路径;用时序图展示“用户→Command→Strategy→Repository”的完整调用链 | + +--- + +## 三、教学过程设计(90分钟) + +| 环节 | 时间 | 教学内容 | 师生活动 | AI协同点 | +| -------------------------- | --- | ----------------------------------------------------------------- | -------------------------------------- | --------------------------- | +| **1. W9回顾与痛点暴露** | 8' | 回顾W9成果(CLI骨架),暴露两大隐患:①CrawlCommand里解析逻辑硬编码;②List\全员可读可写 | **教师演示**:展示W9代码,用“事故场景”引发思考 | — | +| **2. 策略模式:解析器的“插头标准化”** | 18' | 策略模式定义、接口设计、多态调用、与Command模式的对比 | **类比**:插座与电器;**教师演示**:从if-else到策略模式的演进 | 让AI生成“策略模式vs switch-case”对比 | +| **3. 解析器工厂:自动匹配的魔法** | 14' | 工厂模式的两种形态(简单工厂→Map注册工厂),解析器工厂实现 | **教师演示**:先用if-else判断host,再升级为Map注册工厂 | 让AI解释工厂模式与策略模式如何协同 | +| **4. Repository模式:武装数据访问** | 12' | Repository定义、接口设计、替换List\后的影响 | **教师演示**:在原代码中把List替换为Repository,展示改动点 | 学生用AI审计Repository接口的“最小完备性” | +| **5. 整体架构串联** | 8' | 用一张时序图串联:用户→CLI→Controller→Command→Strategy→Repository→Model | **师生互动**:让学生在白板上画出调用链 | — | +| **6. 代码落地** | 20' | 实现CrawlStrategy接口 + 两个策略 + 解析器工厂 + ArticleRepository | **教师演示**:分步写出代码,刻意埋入“策略匹配失败”的异常处理 | 完成后用AI检查策略模式实现 | +| **7. 架构反思与W11预告** | 5' | 当前架构还有什么隐患?(异常处理不统一、日志缺失)→ 预告W11健壮性工程 | **师生互动**:如果解析器工厂找不到匹配策略,会发生什么? | — | +| **8. 实践任务** | 5' | 实现策略模式和仓库层,完成本周代码升级 | 学生现场编码,教师巡视 | — | + +--- + +## 四、核心教学内容脚本 + +### 4.1 W9回顾与痛点暴露(8分钟) + +**教师口播**: +> "上节课我们搭了一个很漂亮的骨架——CLI+MVC+Command模式。我们先来表扬一下自己:新增一个命令,只要新建一个类,Controller零改动。但请大家想一个问题——" + +**投影展示W9的CrawlCommand存根**: +```java +public class CrawlCommand implements Command { + // ... + public void execute(String[] args, List
articles) { + if (args.length < 2) { + view.printError("Usage: crawl "); + return; + } + view.printInfo("Stub: Would crawl " + args[1]); + } +} +``` + +**提问引导**: +1. "这个存根下周要填坑了。假设我们现在要真正实现爬取,代码写在哪?" +2. "如果我要支持两个网站——比如一个技术博客和一个新闻网站——它们的HTML结构完全不一样,这个`execute`方法会变成什么样?" + +**展示“噩梦版”CrawlCommand**: +```java +public void execute(String[] args, List
articles) { + String url = args[1]; + // 五十行if-else地狱... + if (url.contains("blog.example.com")) { + // 解析技术博客的HTML + Document doc = Jsoup.connect(url).get(); + Elements titles = doc.select(".post-title"); + for (Element e : titles) { + articles.add(new Article(e.text(), url, "")); + } + } else if (url.contains("news.example.com")) { + // 解析新闻网站的HTML + Document doc = Jsoup.connect(url).get(); + Elements items = doc.select(".article-headline"); + for (Element e : items) { + articles.add(new Article(e.text(), url, "")); + } + } else { + view.printError("Unsupported website!"); + } +} +``` + +**痛点提炼**: +> "看到了吗?每支持一个新网站,就要在这里加一个`else if`。这就是W1我们痛批的'牵一发而动全身',只不过这次灾难地点从`main`搬到了`CrawlCommand`。" +> +> "更重要的是,我们上节课辛辛苦苦实现了Command模式,难道解析逻辑又要回到if-else地狱吗?**这就是W10要解决的第一个问题:怎么让解析逻辑也可插拔?**" + +**第二个隐患——共享状态的回顾**: +> "还有一件事,我们上节课结束前提到的:`List
articles`在所有Command之间共享。任何一个Command都可以往里面塞东西、删东西、甚至清空。这是W10要解决的第二个问题:**怎么给数据装上'防盗门'?**" + +--- + +### 4.2 策略模式:解析器的“插头标准化”(18分钟) + +#### 4.2.1 从类比切入 + +**教师口播**: +> "先讲个生活场景。你家里墙上有一个三孔插座,你可以插电视、插电脑、插手机充电器——任何符合这个标准的电器都能用。插座不在乎你是什么电器,它只认接口标准。" + +**类比映射**: + +| 生活场景 | 代码对应 | +|----------|----------| +| 三孔插座 | `CrawlStrategy` 接口 | +| 电视/电脑充电器 | 具体解析策略(BlogStrategy/NewsStrategy) | +| 电流 | 输入:URL + Document;输出:List\ | +| 你(使用者) | CrawlCommand | +| 插座面板 | 解析器工厂 | + +> "策略模式的核心思想就是:**定义一个算法接口,让具体的算法实现可以互相替换,而使用算法的客户端不受影响。**" + +#### 4.2.2 策略模式定义 + +```java +// src/main/java/com/crawler/strategy/CrawlStrategy.java +package com.crawler.strategy; + +import com.crawler.model.Article; +import org.jsoup.nodes.Document; +import java.util.List; + +public interface CrawlStrategy { + /** + * 从已获取的Document中解析文章列表 + * @param url 原始请求URL(用于填充Article) + * @param doc Jsoup解析后的Document + * @return 解析出的文章列表 + */ + List
parse(String url, Document doc); + + /** + * 判断此策略是否为给定URL服务 + * @param url 待判断的URL + * @return true表示此策略可以处理该URL + */ + boolean supports(String url); +} +``` + +**教师口播**: +> "注意,策略接口里有两个方法。`parse`是干活的那个,`supports`是'我能不能干这个活'——这是什么?**这是合同!** 任何网站想被我们爬虫支持,就必须签署这份合同:告诉我你是不是我的客户(supports),以及怎么解析你(parse)。" + +#### 4.2.3 具体策略实现示例 + +```java +// BlogStrategy.java - 技术博客解析策略 +public class BlogStrategy implements CrawlStrategy { + @Override + public boolean supports(String url) { + return url.contains("blog.example.com"); + } + + @Override + public List
parse(String url, Document doc) { + List
articles = new ArrayList<>(); + Elements titles = doc.select(".post-title"); + for (Element e : titles) { + articles.add(new Article(e.text(), url, "")); + } + return articles; + } +} + +// NewsStrategy.java - 新闻网站解析策略 +public class NewsStrategy implements CrawlStrategy { + @Override + public boolean supports(String url) { + return url.contains("news.example.com"); + } + + @Override + public List
parse(String url, Document doc) { + List
articles = new ArrayList<>(); + Elements items = doc.select(".article-headline"); + for (Element e : items) { + articles.add(new Article(e.text(), url, "")); + } + return articles; + } +} +``` + +**对比:策略模式 vs 硬编码if-else** + +| 维度 | if-else屎山 | 策略模式 | +|------|-------------|----------| +| 新增网站 | 改CrawlCommand,加else if | 新写一个类,实现CrawlStrategy | +| 修改解析逻辑 | 在CrawlCommand里翻找对应的else if | 只改对应策略类 | +| 测试 | 必须启动整个爬虫 | 单独对Strategy做单元测试 | +| 是否符合开闭原则 | ❌ 对修改开放 | ✅ 对扩展开放,对修改关闭 | + +**与Command模式的对比(加深理解)**: +> "上节课Command模式,我们为每个命令定义一个类;这节课策略模式,我们为每个网站的解析算法定义一个类。**本质上都是同一个OOP思想:用多态替代条件分支。** 只不过Command的接口是`execute()`,Strategy的接口是`parse()`。" +> +> "这张图你们可以记下来:**接口是消除if-else的利器,多态是接口的灵魂。**" + +--- + +### 4.3 解析器工厂:自动匹配的魔法(14分钟) + +#### 4.3.1 问题引出 + +**教师口播**: +> "现在我们有A网站的策略、B网站的策略。问题来了:谁来选策略?谁来遍历所有策略,找到一个supports返回true的?" +> +> "如果把这个逻辑写在CrawlCommand里,那策略模式就白用了——CrawlCommand还是得'知道'有哪些策略。我们要的是一个黑盒子:**把URL丢进去,自动弹出一个合适的解析器。**" + +#### 4.3.2 解析器工厂的实现 + +```java +// src/main/java/com/crawler/strategy/StrategyFactory.java +package com.crawler.strategy; + +import java.util.ArrayList; +import java.util.List; + +public class StrategyFactory { + private final List strategies = new ArrayList<>(); + + // 注册策略——新的网站只需在这里加一行 + public StrategyFactory() { + strategies.add(new BlogStrategy()); + strategies.add(new NewsStrategy()); + // 未来增加新网站:strategies.add(new XxxStrategy()); + } + + /** + * 根据URL自动匹配解析策略 + * @param url 目标URL + * @return 匹配的策略,如果没有匹配返回null + */ + public CrawlStrategy getStrategy(String url) { + for (CrawlStrategy s : strategies) { + if (s.supports(url)) { + return s; + } + } + return null; // 未找到匹配策略 + } +} +``` + +**教师口播**: +> "这个工厂类足够简单:一个List存所有策略,一个方法遍历找到匹配的。但简单不等于不强大。** +> +> **关键点**:新增网站支持,只需要——" +1. 写一个`XxxStrategy`实现`CrawlStrategy` +2. 在工厂构造器里加一行`strategies.add(new XxxStrategy())` +> +> "CrawlCommand一行不改。这就是开闭原则的胜利。" + +#### 4.3.3 从简单工厂到更高级的注册机制(拓展思维) + +**教师口播**: +> "有同学可能会问:还要在工厂构造器里加一行,能不能做到完全零改动?当然可以——用反射或者SPI。" + +**演示概念(不要求实现)**: +```java +// 进阶思路:扫描指定包下的所有CrawlStrategy实现类 +// 用反射自动注册,真正做到“新增类即生效” +// 这是Spring框架的核心思想之一 +``` + +> "这个技术我们暂时不要求掌握,但我希望你们知道:你现在写的每一个`new XxxStrategy()`,在未来都可能进化为框架级别的自动装配。**你现在建立的思维习惯,决定了你未来能走多高。**" + +#### 4.3.4 重构后的CrawlCommand + +```java +public class CrawlCommand implements Command { + private ConsoleView view; + private StrategyFactory strategyFactory; + private ArticleRepository repository; // 注意:这里是Repository了! + + public CrawlCommand(ConsoleView v, StrategyFactory f, ArticleRepository r) { + this.view = v; + this.strategyFactory = f; + this.repository = r; + } + + public String getName() { return "crawl"; } + + public void execute(String[] args, List
articles) { + if (args.length < 2) { + view.printError("Usage: crawl "); + return; + } + String url = args[1]; + + // 1. 工厂自动选策略 + CrawlStrategy strategy = strategyFactory.getStrategy(url); + if (strategy == null) { + view.printError("No strategy found for: " + url); + return; + } + + // 2. 抓取页面 + view.printInfo("Crawling: " + url); + try { + Document doc = Jsoup.connect(url).get(); + List
parsed = strategy.parse(url, doc); + + // 3. 通过仓库存入(而不是直接操作List) + for (Article a : parsed) { + repository.add(a); + } + view.printSuccess("Crawled " + parsed.size() + " articles."); + } catch (IOException e) { + view.printError("Failed to crawl: " + e.getMessage()); + } + } +} +``` + +**教师口播**: +> "注意这个CrawlCommand现在的职责:拿到URL → 交给工厂选策略 → 执行解析 → 交给仓库存储。**它自己在干什么?在调度!** 这就是上节课我们讲的Controller的'调度思维',现在向Command内部延伸了。" + +--- + +### 4.4 Repository模式:武装数据访问(12分钟) + +#### 4.4.1 问题重提 + +**教师口播**: +> "回到上节课结束时的那个问题:`List
`在所有Command之间共享。任何一个Command都可以做这些事——" +```java +articles.clear(); // 清空所有文章 +articles.add(null); // 塞入null +articles.remove(0); // 随意删除 +``` + +> "如果一个新同事接手开发,他不知道'不要动这个List'的潜规则,写了一个`articles.clear()`,你的`list`命令就突然什么都不显示了。**靠代码约定维护的秩序,早晚会被打破。我们需要实体的'规则'——代码层面的约束。**" + +#### 4.4.2 ArticleRepository的定义 + +```java +// src/main/java/com/crawler/repository/ArticleRepository.java +package com.crawler.repository; + +import com.crawler.model.Article; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class ArticleRepository { + private final List
articles = new ArrayList<>(); + + /** + * 添加一篇文章。注意:不接受null,这是代码层面的规则,不是口头约定。 + */ + public void add(Article article) { + if (article == null) { + throw new IllegalArgumentException("Article cannot be null"); + } + articles.add(article); + } + + /** + * 获取所有文章的只读视图 + * 调用者无法通过此返回值修改内部数据 + */ + public List
getAll() { + return Collections.unmodifiableList(articles); + } + + /** + * 获取文章数量 + */ + public int size() { + return articles.size(); + } + + /** + * 清空(仅管理员可调——下一篇:权限控制) + */ + public void clear() { + articles.clear(); + } +} +``` + +**教师口播**: +> "三个关键设计点——" +> +> - **add()拒绝null**:规则写在代码里,不是写在邮件里 +> - **getAll()返回不可修改的视图**:`Collections.unmodifiableList()`——调用者如果尝试add/remove,会**直接抛异常**,不是'悄悄的bug' +> - **ClearCommand要清空数据?调`repository.clear()`**,而不是直接操作List +> +> "这就是面向对象的第一课——封装。把数据藏起来,只暴露安全的方法。从'直接操作集合'到'通过仓库存取',是程序员成熟度的分水岭。" + +#### 4.4.3 仓库引入后的架构变化 + +**Command接口的execute方法调整**: + +```java +// 调整前(W9) +public interface Command { + String getName(); + void execute(String[] args, List
articles); +} + +// 调整后(W10) +public interface Command { + String getName(); + void execute(String[] args, ArticleRepository repository); +} +``` + +**教师口播**: +> "这个改动很小——把`List
`换成`ArticleRepository`。但语义完全不同:之前是'给你数据随便玩',现在是'给你一个安全的存取通道'。" + +**所有Command同步调整**: + +```java +// ListCommand.java - 调整后 +public class ListCommand implements Command { + private ConsoleView view; + public ListCommand(ConsoleView v) { this.view = v; } + public String getName() { return "list"; } + public void execute(String[] args, ArticleRepository repository) { + view.display(repository.getAll()); // 通过仓库获取数据 + } +} + +// ClearCommand.java(新增示例) +public class ClearCommand implements Command { + private ConsoleView view; + public ClearCommand(ConsoleView v) { this.view = v; } + public String getName() { return "clear"; } + public void execute(String[] args, ArticleRepository repository) { + repository.clear(); + view.printSuccess("All articles cleared."); + } +} +``` + +**Controller和main的调整**: + +```java +// App.java - 调整后 +public class App { + public static void main(String[] args) { + ConsoleView view = new ConsoleView(); + ArticleRepository repository = new ArticleRepository(); // 替代 List
+ StrategyFactory factory = new StrategyFactory(); // 新增 + + CrawlerController controller = new CrawlerController(view, repository, factory); + + view.printSuccess("Welcome to CLI Crawler v2.0!"); + view.printInfo("Type 'help' for commands."); + + while (true) { + controller.handle(view.readLine()); + } + } +} +``` + +--- + +### 4.5 整体架构串联(8分钟) + +**教师口播**: +> "现在我们把所有部件串起来,看看一个`crawl https://blog.example.com`命令走过的完整路径。" + +**时序图(口述配白板绘制)**: +``` +用户输入 "crawl https://blog.example.com" + │ + ▼ +ConsoleView.readLine() + │ + ▼ +CrawlerController.handle("crawl https://blog.example.com") + │ Map查找 "crawl" → CrawlCommand + ▼ +CrawlCommand.execute(args, repository) + │ + ├─► StrategyFactory.getStrategy(url) + │ │ 遍历List + │ │ BlogStrategy.supports(url) → true! + │ ▼ + │ 返回 BlogStrategy + │ + ├─► Jsoup.connect(url).get() → Document + │ + ├─► BlogStrategy.parse(url, doc) → List
+ │ + └─► for each article: repository.add(article) + │ + ▼ + ArticleRepository.articles.add(article) + +最终:ConsoleView.printSuccess("Crawled N articles.") +``` + +**教师口播**: +> "七步调用,每一步职责清晰:View负责输入输出,Controller负责路由,Command负责调度,Factory负责匹配,Strategy负责解析,Repository负责存储。**没有哪个类干了两个人的活,也没有哪个类不知道自己的活是什么。**" +> +> "这就是工程化——不是把代码写得快,是把代码写得对。" + +--- + +### 4.6 代码落地(20分钟) + +**教师准备**:课前准备一份“W9升级到W10”的改动清单,现场演示关键改动。 + +**改动清单**: +1. 新建`strategy/`包,创建`CrawlStrategy`接口 +2. 新建`strategy/BlogStrategy.java` +3. 新建`strategy/NewsStrategy.java` +4. 新建`strategy/StrategyFactory.java` +5. 新建`repository/`包,创建`ArticleRepository.java` +6. 修改`Command`接口的`execute`签名 +7. 修改`CrawlCommand`,引入`StrategyFactory`和`ArticleRepository` +8. 修改其余所有`Command`实现类 +9. 修改`CrawlerController`构造器 +10. 修改`App.java` + +**教师演示关键步骤**(重点演示): +- `ArticleRepository`的`Collections.unmodifiableList()` +- `StrategyFactory`的遍历匹配逻辑 +- `CrawlCommand`重写后的调度结构 + +**刻意埋入的“找茬点”**: +> "我在`StrategyFactory.getStrategy()`里,如果没有匹配的策略就返回`null`。然后在`CrawlCommand`里检查null。这其实叫'null object pattern的前奏'——如果我不想让Command检查null,我应该怎么改工厂?大家带着这个问题用AI探究。" + +--- + +### 4.7 架构反思与W11预告(5分钟) + +**教师口播**: +> "现在我们的架构比W9强壮多了:解析逻辑可插拔,数据访问有守卫。但还有一些漏洞——" + +**逐一点破**: +1. **异常处理**:`CrawlCommand`用了一个笼统的`catch (IOException e)`,如果解析过程中抛出其他异常怎么办? +2. **网络超时**:如果目标网站3秒没响应,当前代码会一直等吗? +3. **日志缺失**:所有的成功/失败信息只输出到终端,如果程序半夜跑,第二天想看昨晚抓了多少——看不了。 +4. **重试机制**:如果一次失败就直接报错,要不要给个重试的机会? + +**W11预告**: +> "下周,我们会做三件事:**自定义异常体系**、**工程化日志框架**、**防御式编程与重试机制**。W9搭骨架,W10装盔甲,W11要让这个系统**经得起现实的毒打**。" + +--- + +### 4.8 实践任务(5分钟) + +**任务要求**: +1. 从W9代码出发,完成W10升级 +2. 实现至少两个`CrawlStrategy`(可以是模拟的,不要求真实爬取) +3. 实现`StrategyFactory`和`ArticleRepository` +4. 确保所有Command通过Repository访问数据 +5. 运行并测试完整流程 + +**验收标准**: +- [x] 新增策略类只需新建文件+工厂注册一行,其余代码零改动 +- [x] `ArticleRepository`的`getAll()`返回不可修改视图 +- [x] `CrawlCommand`不包含任何网站特定的解析逻辑 +- [x] `StrategyFactory`能根据URL自动匹配正确的策略 +- [x] 所有Command的`execute`方法签名已更新为`ArticleRepository` +- [x] 无任何地方直接操作`List
` + +--- + +## 五、课后作业 + +### 5.1 必做任务 + +1. **完善ArticleRepository**:增加`addAll(List
)`批量添加方法,注意防御null +2. **★ AnalyzeCommand(集大成作业)**: + - 实现`analyze `命令 + - 内部调用`StrategyFactory`匹配策略 + - 调用策略解析文章后,**不存到Repository**,而是分析统计信息: + - 文章总数 + - 标题平均长度 + - 按某种规则排名的Top 5 + - 结果只输出,不存储 + - **提示**:这就是策略的复用——同一个解析策略,既能为`crawl`服务(存入仓库),也能为`analyze`服务(仅分析) + +3. **AI架构审计**:将完整代码的类图(或类名与方法签名列表)发给AI,指令: + > "作为Java架构审计师,请检查:①策略模式的实现是否正确解耦(CrawlCommand是否仍然包含网站特定逻辑);②Repository是否真正封装了数据访问(是否存在绕过Repository直接操作List的地方);③工厂的匹配逻辑是否存在性能隐患。请给出具体的改进建议。" + +### 5.2 选做任务 + +1. **正则策略匹配**:将`Supports()`的判断从`url.contains()`改为正则表达式,让一张策略可以匹配一类URL +2. **默认策略(DefaultStrategy)**:当没有策略匹配时,提供一个通用的“标题提取”逻辑 +3. **策略优先级**:给每个策略加一个`priority`字段,工厂按优先级匹配(而不是按注册顺序) +4. **思考并回答(200字)**: + > "策略模式中,策略的`supports()`方法有可能让两个策略都返回true,这时该选哪个?`StrategyFactory`的遍历顺序会如何影响结果?你有什么解决方案?" + +### 5.3 思考题 + +1. **Repository与List的区别是什么?** 如果Repository只是包了一层List,为什么还要用? +2. **策略工厂的演进**:如果网站数量增加到100个,逐个注册的写法还合适吗?你想到什么解决方案? +3. **`Collections.unmodifiableList()`返回的是什么?** 它真的“不可修改”吗?如果原List被修改,这个不可修改视图会怎样? + +--- + +## 六、AI协同升级 + +### 架构审计师任务(必做) + +**学生执行步骤**: +1. 画出当前项目的类依赖图(手绘或工具生成) +2. 将类名和依赖关系发给AI +3. 输入指令: + > "作为Java架构审计师,请检查这个爬虫项目的架构。重点关注:①策略模式是否真正实现了开闭原则(增加新网站是否真的只需新增类);②Repository封装是否完整(是否有绕过Repository的路径);③是否存在循环依赖。请逐一指出问题并给出改进建议。" + +**预期AI输出**: +- 指出是否还存在“改一处影响多处”的耦合 +- 判断Repository的API设计是否完备 +- 评价整体架构的开闭原则达成度 + +### 进阶AI探究(选做) + +> "假设我有一个CrawlStrategy接口和10个实现类。不用工厂模式,直接用一个Map存起来,key是策略名称。这和StrategyFactory设计有什么本质区别?各自的优缺点是什么?" + +--- + +## 七、教学反思与调整记录 + +| 日期 | 事项 | 调整内容 | +|------|------|----------| +| 2026-05-01 | 首次编写 | 基于W9骨架,引入策略模式+工厂+Repository | +| 2026-05-07 | 结构优化 | 调整策略模式与工厂的讲解顺序,先策略后工厂更自然 | + +--- + +## 附录1:W9到W10改动对照表 + +| 改动项 | W9代码 | W10代码 | +|--------|--------|---------| +| 数据存储 | `List
articles` | `ArticleRepository repository` | +| Command接口 | `execute(String[], List
)` | `execute(String[], ArticleRepository)` | +| 解析逻辑位置 | `CrawlCommand`内部 | 各`CrawlStrategy`实现类 | +| URL匹配 | 无(硬编码) | `StrategyFactory.getStrategy(url)` | +| 数据添加 | `articles.add(article)` | `repository.add(article)` | +| 数据读取 | 直接遍历`articles` | `repository.getAll()` | + +## 附录2:常见问题速查 + +| 问题 | 解答 | +|------|------| +| 策略模式和Command模式有什么区别? | Command封装“动作”(做什么事),Strategy封装“算法”(怎么做)。在爬虫中:crawl是命令(动作),如何解析是策略(算法)。 | +| 工厂一定要叫Factory吗? | 不必须。但叫Factory意味着“创建对象”的职责,符合模式命名的惯例。 | +| `Collections.unmodifiableList()`有什么用? | 返回一个只读视图,调用add/remove等方法会抛`UnsupportedOperationException`。 | +| Repository和DAO有什么区别? | 在我们的上下文中可以视为同义词。严谨地说,Repository是领域驱动设计的概念,更偏向“集合语义”;DAO更偏数据库操作。 | +| 策略的`supports()`返回true但解析失败怎么办? | 那是策略实现的bug,该策略应修复。Factory不负责验证策略的正确性。 | + +## 附录3:教学逻辑说明 + +| 顺序 | 内容 | 设计理由 | +|------|------|----------| +| 1 | W9回顾+痛点暴露 | 承上启下,从已知问题引出新知识 | +| 2 | 策略模式 | 解决解析逻辑耦合问题,深化多态理解 | +| 3 | 解析器工厂 | 解决策略选择问题,引入工厂模式 | +| 4 | Repository模式 | 解决数据安全问题,实践封装原则 | +| 5 | 架构串联 | 将所有部件统一,形成完整心智模型 | +| 6 | 代码落地 | 实践验证,从“听懂”到“会做” | +| 7 | 架构反思+预告 | 暴露新问题,为W11健壮性工程铺垫 | + +--- + +## 版本说明 + +- **v1(本版)**:基于W9教案模式首次编写,包含策略模式、工厂模式、Repository模式的完整引入 \ No newline at end of file diff --git a/w3/BaseCrawler.java b/w3/BaseCrawler.java new file mode 100644 index 0000000..453f0f2 --- /dev/null +++ b/w3/BaseCrawler.java @@ -0,0 +1,145 @@ +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; + +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.IOException; + +/** + * 通用爬虫父类 + * 封装通用功能,定义抽象方法让子类实现具体解析逻辑 + */ +public abstract class BaseCrawler { + // 通用请求头设置 + protected static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + protected static final String ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"; + protected static final String ACCEPT_LANGUAGE = "zh-CN,zh;q=0.9"; + protected static final int TIMEOUT = 30000; + + // 延时时间(毫秒) + protected static final int DELAY_MS = 1000; + + // 进度统计 + protected int totalCount = 0; + protected int targetCount = 0; + + /** + * 抽象方法:解析数据 + * 子类必须实现具体的解析逻辑 + */ + protected abstract void parseData(Document doc, BufferedWriter writer) throws IOException, InterruptedException; + + /** + * 运行爬虫 + * @param url 目标URL + * @param outputFile 输出文件路径 + * @param targetCount 目标数量(用于进度计算) + */ + public void run(String url, String outputFile, int targetCount) { + this.targetCount = targetCount; + totalCount = 0; + + System.out.println("开始爬取数据..."); + System.out.println("目标:" + targetCount + "个项目\n"); + + try (BufferedWriter writer = new BufferedWriter(new FileWriter(outputFile))) { + // 写入CSV表头 + writeHeader(writer); + + System.out.println("正在访问页面..."); + + // 发送HTTP请求获取页面 + Document doc = fetchDocument(url); + + System.out.println("页面标题:" + doc.title()); + + // 调用子类实现的解析方法 + parseData(doc, writer); + + // 输出结果 + printResult(outputFile); + + } catch (IOException e) { + System.err.println("爬取失败:" + e.getMessage()); + e.printStackTrace(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** + * 获取页面文档 + * @param url 目标URL + * @return 页面文档 + * @throws IOException 网络异常 + */ + protected Document fetchDocument(String url) throws IOException { + return Jsoup.connect(url) + .userAgent(USER_AGENT) + .timeout(TIMEOUT) + .header("Accept", ACCEPT) + .header("Accept-Language", ACCEPT_LANGUAGE) + .followRedirects(true) + .get(); + } + + /** + * 写入CSV表头 + * 子类可以重写此方法以自定义表头 + */ + protected void writeHeader(BufferedWriter writer) throws IOException { + writer.write("项目名称,类别,地区,简介"); + writer.newLine(); + } + + /** + * 延时防反爬 + */ + protected void delay() throws InterruptedException { + Thread.sleep(DELAY_MS); + } + + /** + * 清理CSV字段中的特殊字符 + * @param field 字段值 + * @return 清理后的字段值 + */ + protected String cleanCsvField(String field) { + if (field == null) { + return ""; + } + // 移除换行符和制表符 + field = field.replace("\n", " ").replace("\r", " ").replace("\t", " "); + // 移除引用标记 + field = field.replace("[", "").replace("]", ""); + // 如果包含逗号,用双引号包裹 + if (field.contains(",")) { + field = "\"" + field.replace("\"", "\"\"") + "\""; + } + return field; + } + + /** + * 打印进度 + * @param count 当前处理数量 + */ + protected void printProgress(int count) { + if (count % 100 == 0) { + System.out.println(" 已爬取 " + count + " 个项目..."); + } + } + + /** + * 打印结果 + * @param outputFile 输出文件路径 + */ + protected void printResult(String outputFile) { + System.out.println("\n========================================"); + System.out.println("全部爬取完成!"); + System.out.println("共爬取 " + totalCount + " 个项目"); + System.out.println("目标:" + targetCount + "个项目"); + System.out.println("完成率:" + String.format("%.2f", (totalCount / (double) targetCount) * 100) + "%"); + System.out.println("========================================"); + System.out.println("数据已保存到:" + outputFile); + } +} diff --git a/w3/IntangibleHeritageCrawler.class b/w3/IntangibleHeritageCrawler.class new file mode 100644 index 0000000000000000000000000000000000000000..5aeeb721265a3e8062a265a547939c4594439cfb GIT binary patch literal 5586 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 LzLZU4*TeZ=IgVKu literal 0 HcmV?d00001 diff --git a/w3/IntangibleHeritageCrawler.java b/w3/IntangibleHeritageCrawler.java new file mode 100644 index 0000000..c2c322f --- /dev/null +++ b/w3/IntangibleHeritageCrawler.java @@ -0,0 +1,105 @@ +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; + +import java.io.BufferedWriter; +import java.io.IOException; + +/** + * 中国非物质文化遗产爬虫 + * 爬取 Wikipedia 上的国家级非遗项目列表(1557项) + * 继承自 BaseCrawler,实现具体的解析逻辑 + */ +public class IntangibleHeritageCrawler extends BaseCrawler { + + // Wikipedia 非遗列表页面 + private static final String WIKIPEDIA_URL = "https://zh.wikipedia.org/wiki/国家级非物质文化遗产代表性项目名录"; + // 输出文件路径 + private static final String OUTPUT_FILE = "intangible_heritage.csv"; + // 目标项目数量 + private static final int TARGET_COUNT = 1557; + + public static void main(String[] args) { + // 使用多态:父类引用指向子类对象 + BaseCrawler crawler = new IntangibleHeritageCrawler(); + // 调用父类的通用 run 方法,内部会自动执行子类的 parseData 实现 + crawler.run(WIKIPEDIA_URL, OUTPUT_FILE, TARGET_COUNT); + } + + /** + * 重写父类的抽象方法 parseData + * 实现维基百科非遗表格的解析逻辑 + */ + @Override + protected void parseData(Document doc, BufferedWriter writer) throws IOException, InterruptedException { + // Wikipedia 的表格通常有特定的class + Elements tables = doc.select("table.wikitable, table.sortable"); + System.out.println("找到 " + tables.size() + " 个表格\n"); + + // 遍历所有表格 + for (Element table : tables) { + // 提取表格标题(类别) + String category = ""; + Element caption = table.selectFirst("caption"); + if (caption != null) { + category = caption.text().trim(); + } + + // 提取表格行 + Elements rows = table.select("tr"); + System.out.println("表格:" + category + " - 共 " + rows.size() + " 行"); + + int tableCount = 0; + for (Element row : rows) { + try { + // 提取单元格 + Elements cells = row.select("td"); + + if (cells.size() >= 2) { + // 第一列通常是项目名称 + String name = cells.get(0).text().trim(); + + // 第二列通常是地区 + String region = cells.get(1).text().trim(); + + // 如果有第三列,可能是简介或批次 + String description = ""; + if (cells.size() >= 3) { + description = cells.get(2).text().trim(); + } + + // 清理数据(使用父类提供的方法) + name = cleanCsvField(name); + category = cleanCsvField(category); + region = cleanCsvField(region); + description = cleanCsvField(description); + + // 如果项目名称不为空且不是表头,则写入CSV + if (!name.isEmpty() && + !name.equals("项目名称") && + !name.equals("名称") && + !name.equals("序号") && + name.length() > 1) { + + writer.write(String.format("%s,%s,%s,%s", name, category, region, description)); + writer.newLine(); + tableCount++; + totalCount++; + + // 打印进度(使用父类提供的方法) + printProgress(totalCount); + } + } + } catch (Exception e) { + System.err.println(" 解析行时出错:" + e.getMessage()); + } + } + + System.out.println(" 该表格爬取完成!共 " + tableCount + " 个项目\n"); + + // 延时,避免请求过快(使用父类提供的方法) + delay(); + } + } +} + diff --git a/w3/intangible_heritage.csv b/w3/intangible_heritage.csv new file mode 100644 index 0000000..ccb49bf --- /dev/null +++ b/w3/intangible_heritage.csv @@ -0,0 +1,248 @@ +项目名称,类别,地区,简介 +董永传说,民间文学,湖北省孝感市,中国古代四大民间传说之一 +牛郎织女传说,民间文学,山东省沂源县,中国古代四大民间传说之一 +孟姜女传说,民间文学,山东省淄博市,中国古代四大民间传说之一 +梁山伯与祝英台传说,民间文学,浙江省宁波市,中国古代四大民间传说之一 +白蛇传传说,民间文学,江苏省镇江市,中国古代四大民间传说之一 +刘三姐歌谣,民间文学,广西壮族自治区,壮族民间歌谣 +玛纳斯,民间文学,新疆维吾尔自治区,柯尔克孜族英雄史诗 +格萨(斯)尔,民间文学,西藏自治区,藏族英雄史诗 +江格尔,民间文学,新疆维吾尔自治区,蒙古族英雄史诗 +阿诗玛,民间文学,云南省石林彝族自治县,彝族叙事长诗 +苗族古歌,民间文学,贵州省台江县,苗族创世史诗 +遮帕麻和遮咪麻,民间文学,云南省梁河县,阿昌族创世神话 +牡帕密帕,民间文学,云南省澜沧拉祜族自治县,拉祜族创世史诗 +刻道,民间文学,贵州省施秉县,苗族刻木记事符号 +亚鲁王,民间文学,贵州省紫云苗族布依族自治县,苗族英雄史诗 +目瑙斋瓦,民间文学,云南省德宏傣族景颇族自治州,景颇族创世史诗 +梅葛,民间文学,云南省楚雄彝族自治州,彝族创世史诗 +查姆,民间文学,云南省双柏县,彝族创世史诗 +达斡尔族乌钦,民间文学,黑龙江省,达斡尔族曲艺形式 +蒙古族长调民歌,传统音乐,内蒙古自治区,蒙古族传统音乐形式 +蒙古族呼麦,传统音乐,内蒙古自治区,蒙古族独特的歌唱艺术 +花儿,传统音乐,甘肃省,西北地区民歌形式 +侗族大歌,传统音乐,贵州省黎平县,侗族多声部民歌 +古琴艺术,传统音乐,中国艺术研究院,中国最古老的弹拨乐器之一 +南音,传统音乐,福建省泉州市,中国现存最古老的乐种之一 +西安鼓乐,传统音乐,陕西省西安市,唐代宫廷音乐的活化石 +粤剧,传统音乐,广东省广州市,广东主要戏曲剧种 +藏戏,传统音乐,西藏自治区,藏族传统戏曲 +京剧,传统戏剧,北京市,中国国粹,综合性表演艺术 +昆曲,传统戏剧,江苏省苏州市,中国现存最古老的剧种之一 +越剧,传统戏剧,浙江省嵊州市,中国第二大剧种 +黄梅戏,传统戏剧,安徽省安庆市,中国五大戏曲剧种之一 +豫剧,传统戏剧,河南省,中国五大戏曲剧种之一 +评剧,传统戏剧,河北省唐山市,中国五大戏曲剧种之一 +河北梆子,传统戏剧,河北省,河北省主要地方戏曲剧种 +秦腔,传统戏剧,陕西省,中国最古老的戏剧之一 +皮影戏,传统戏剧,陕西省华县,中国民间古老的传统艺术 +木偶戏,传统戏剧,福建省泉州市,中国传统表演艺术 +川剧,传统戏剧,四川省成都市,四川主要地方戏曲剧种 +湘剧,传统戏剧,湖南省长沙市,湖南主要地方戏曲剧种 +汉剧,传统戏剧,湖北省武汉市,湖北主要地方戏曲剧种 +闽剧,传统戏剧,福建省福州市,福建主要地方戏曲剧种 +莆仙戏,传统戏剧,福建省莆田市,福建古老戏曲剧种 +梨园戏,传统戏剧,福建省泉州市,福建古老戏曲剧种 +高甲戏,传统戏剧,福建省泉州市,福建地方戏曲剧种 +歌仔戏,传统戏剧,福建省漳州市,福建地方戏曲剧种 +五音戏,传统戏剧,山东省淄博市,山东地方戏曲剧种 +柳子戏,传统戏剧,山东省,山东古老戏曲剧种 +柳琴戏,传统戏剧,山东省临沂市,山东地方戏曲剧种 +茂腔,传统戏剧,山东省青岛市,山东地方戏曲剧种 +吕剧,传统戏剧,山东省,山东主要地方戏曲剧种 +锡剧,传统戏剧,江苏省无锡市,江苏地方戏曲剧种 +沪剧,传统戏剧,上海市,上海主要地方戏曲剧种 +滑稽戏,传统戏剧,上海市,上海地方戏曲剧种 +扬剧,传统戏剧,江苏省扬州市,江苏地方戏曲剧种 +淮剧,传统戏剧,江苏省盐城市,江苏地方戏曲剧种 +甬剧,传统戏剧,浙江省宁波市,浙江地方戏曲剧种 +绍剧,传统戏剧,浙江省绍兴市,浙江地方戏曲剧种 +婺剧,传统戏剧,浙江省金华市,浙江地方戏曲剧种 +徽剧,传统戏剧,安徽省,安徽古老戏曲剧种 +庐剧,传统戏剧,安徽省合肥市,安徽地方戏曲剧种 +泗州戏,传统戏剧,安徽省宿州市,安徽地方戏曲剧种 +坠子戏,传统戏剧,安徽省宿州市,安徽地方戏曲剧种 +彩调,传统戏剧,广西壮族自治区,广西地方戏曲剧种 +桂剧,传统戏剧,广西壮族自治区,广西地方戏曲剧种 +邕剧,传统戏剧,广西壮族自治区南宁市,广西地方戏曲剧种 +琼剧,传统戏剧,海南省,海南地方戏曲剧种 +藏戏(拉萨觉木隆),传统戏剧,西藏自治区拉萨市,藏戏主要流派 +藏戏(日喀则迥巴),传统戏剧,西藏自治区日喀则市,藏戏主要流派 +藏戏(山南雅隆扎西雪巴),传统戏剧,西藏自治区山南市,藏戏主要流派 +藏戏(昌都香巴),传统戏剧,西藏自治区昌都市,藏戏主要流派 +藏戏(江嘎尔),传统戏剧,西藏自治区日喀则市,藏戏主要流派 +壮剧,传统戏剧,广西壮族自治区,广西地方戏曲剧种 +侗戏,传统戏剧,贵州省黎平县,侗族传统戏曲 +彝剧,传统戏剧,云南省楚雄彝族自治州,彝族传统戏曲 +苗剧,传统戏剧,湖南省湘西土家族苗族自治州,苗族传统戏曲 +布依戏,传统戏剧,贵州省黔西南布依族苗族自治州,布依族传统戏曲 +花灯戏,传统戏剧,贵州省,贵州地方戏曲剧种 +秧歌戏,传统戏剧,河北省,北方民间戏曲形式 +道情戏,传统戏剧,山西省,山西地方戏曲剧种 +二人台,传统戏剧,内蒙古自治区,内蒙古地方戏曲剧种 +皮影戏(唐山皮影戏),传统戏剧,河北省唐山市,皮影戏主要流派 +皮影戏(冀南皮影戏),传统戏剧,河北省邯郸市,皮影戏主要流派 +皮影戏(孝义皮影戏),传统戏剧,山西省孝义市,皮影戏主要流派 +皮影戏(复州皮影戏),传统戏剧,辽宁省瓦房店市,皮影戏主要流派 +皮影戏(海宁皮影戏),传统戏剧,浙江省海宁市,皮影戏主要流派 +皮影戏(江汉平原皮影戏),传统戏剧,湖北省潜江市,皮影戏主要流派 +皮影戏(陆丰皮影戏),传统戏剧,广东省陆丰市,皮影戏主要流派 +皮影戏(华县皮影戏),传统戏剧,陕西省华县,皮影戏主要流派 +皮影戏(华阴老腔),传统戏剧,陕西省华阴市,皮影戏主要流派 +高腔(西安高腔),传统戏剧,浙江省衢州市,高腔主要流派 +高腔(松阳高腔),传统戏剧,浙江省松阳县,高腔主要流派 +高腔(岳西高腔),传统戏剧,安徽省岳西县,高腔主要流派 +高腔(辰河高腔),传统戏剧,湖南省泸溪县,高腔主要流派 +高腔(常德高腔),传统戏剧,湖南省常德市,高腔主要流派 +新昌调腔,传统戏剧,浙江省新昌县,古老戏曲声腔 +宁海平调,传统戏剧,浙江省宁海县,古老戏曲声腔 +永安大腔戏,传统戏剧,福建省永安市,古老戏曲声腔 +四平戏,传统戏剧,福建省屏南县,古老戏曲声腔 +词明戏,传统戏剧,福建省福清市,古老戏曲声腔 +大腔戏,传统戏剧,福建省泰宁县,古老戏曲声腔 +大弦戏,传统戏剧,河南省滑县,古老戏曲声腔 +怀梆,传统戏剧,河南省沁阳市,河南地方戏曲剧种 +淮南紫金山锣鼓,传统音乐,安徽省淮南市,民间锣鼓乐 +太极拳,传统体育、游艺与杂技,河南省温县,中国传统武术拳法 +少林功夫,传统体育、游艺与杂技,河南省登封市,中国武术的重要流派 +武当武术,传统体育、游艺与杂技,湖北省十堰市,中国内家拳法 +杂技,传统体育、游艺与杂技,北京市,中国传统表演艺术 +围棋,传统体育、游艺与杂技,中国,中国传统棋类游戏 +中国象棋,传统体育、游艺与杂技,中国,中国传统棋类游戏 +书法,传统美术,中国,中国传统艺术形式 +中国篆刻,传统美术,浙江省杭州市,中国传统艺术形式 +中国剪纸,传统美术,中国,中国传统民间艺术 +皮影制作技艺,传统美术,陕西省华县,皮影戏道具制作技艺 +泥塑,传统美术,天津市,中国传统民间艺术 +木雕,传统美术,浙江省东阳市,中国传统雕刻艺术 +石雕,传统美术,福建省惠安县,中国传统雕刻艺术 +玉雕,传统美术,江苏省扬州市,中国传统雕刻艺术 +竹编,传统美术,四川省成都市,中国传统编织工艺 +中国刺绣,传统美术,江苏省苏州市,中国传统手工艺 +苏绣,传统美术,江苏省苏州市,中国四大名绣之一 +湘绣,传统美术,湖南省长沙市,中国四大名绣之一 +粤绣,传统美术,广东省广州市,中国四大名绣之一 +蜀绣,传统美术,四川省成都市,中国四大名绣之一 +景德镇手工制瓷技艺,传统技艺,江西省景德镇市,中国瓷器制作技艺的代表 +宜兴紫砂陶制作技艺,传统技艺,江苏省宜兴市,中国特有的手工制造陶土工艺品 +茅台酒酿制技艺,传统技艺,贵州省遵义市,中国酱香型白酒的典型代表 +泸州老窖酒酿制技艺,传统技艺,四川省泸州市,中国浓香型白酒的典型代表 +五粮液酒酿制技艺,传统技艺,四川省宜宾市,中国浓香型白酒的典型代表 +宣纸制作技艺,传统技艺,安徽省泾县,中国传统书画用纸 +湖笔制作技艺,传统技艺,浙江省湖州市,中国文房四宝之一 +徽墨制作技艺,传统技艺,安徽省歙县,中国文房四宝之一 +端砚制作技艺,传统技艺,广东省肇庆市,中国文房四宝之一 +中医诊法,传统医药,中国,中国传统医学诊疗方法 +中医针灸,传统医药,中国,中国传统医学治疗方法 +中药炮制技艺,传统医药,中国,中药传统加工技术 +中医传统制剂方法,传统医药,中国,中药传统制剂技术 +同仁堂中医药文化,传统医药,北京市,中华老字号中医药文化 +胡庆余堂中药文化,传统医药,浙江省杭州市,中华老字号中医药文化 +藏医药,传统医药,西藏自治区,藏族传统医学 +蒙医药,传统医药,内蒙古自治区,蒙古族传统医学 +苗医药,传统医药,贵州省,苗族传统医学 +春节,民俗,中国,中华民族最隆重的传统节日 +清明节,民俗,中国,中国传统节日 +端午节,民俗,中国,纪念屈原的传统节日 +中秋节,民俗,中国,中国传统团圆节日 +重阳节,民俗,中国,中国传统敬老节日 +元宵节,民俗,中国,中国传统节日 +七夕节,民俗,中国,中国传统情人节 +那达慕大会,民俗,内蒙古自治区,蒙古族传统节日 +泼水节,民俗,云南省,傣族传统节日 +火把节,民俗,四川省凉山彝族自治州,彝族传统节日 +龙舟竞渡,民俗,湖南省汨罗市,端午节传统民俗活动 +妈祖祭典,民俗,福建省莆田市,祭祀妈祖的民俗活动 +祭孔大典,民俗,山东省曲阜市,祭祀孔子的典礼 +黄帝陵祭典,民俗,陕西省黄陵县,祭祀黄帝的典礼 +祭炎帝神农氏大典,民俗,湖北省随州市,祭祀炎帝的典礼 +太昊伏羲祭典,民俗,甘肃省天水市,祭祀伏羲的典礼 +女娲祭典,民俗,河北省涉县,祭祀女娲的典礼 +大禹祭典,民俗,浙江省绍兴市,祭祀大禹的典礼 +成吉思汗祭典,民俗,内蒙古自治区鄂尔多斯市,蒙古族祭祀活动 +祭敖包,民俗,内蒙古自治区,蒙古族祭祀活动 +白族三月街,民俗,云南省大理白族自治州,白族传统节日 +壮族三月三,民俗,广西壮族自治区,壮族传统节日 +瑶族盘王节,民俗,广西壮族自治区,瑶族传统节日 +苗族姊妹节,民俗,贵州省台江县,苗族传统节日 +彝族年,民俗,四川省凉山彝族自治州,彝族传统节日 +藏历年,民俗,西藏自治区,藏族传统节日 +回族花儿会,民俗,甘肃省,回族传统节日 +傈僳族刀杆节,民俗,云南省怒江傈僳族自治州,傈僳族传统节日 +哈尼族扎勒特,民俗,云南省红河哈尼族彝族自治州,哈尼族传统节日 +景颇族目瑙纵歌,民俗,云南省德宏傣族景颇族自治州,景颇族传统节日 +黎族三月三,民俗,海南省,黎族传统节日 +高山族丰收节,民俗,台湾省,高山族传统节日 +水族端节,民俗,贵州省三都水族自治县,水族传统节日 +羌族瓦尔俄足节,民俗,四川省阿坝藏族羌族自治州,羌族传统节日 +苗族鼓藏节,民俗,贵州省雷山县,苗族传统节日 +侗族萨玛节,民俗,贵州省榕江县,侗族传统节日 +苗族芦笙节,民俗,贵州省,苗族传统节日 +傣族开门节,民俗,云南省西双版纳傣族自治州,傣族传统节日 +傣族关门节,民俗,云南省西双版纳傣族自治州,傣族传统节日 +布朗族关门节,民俗,云南省西双版纳傣族自治州,布朗族传统节日 +基诺族特懋克节,民俗,云南省西双版纳傣族自治州,基诺族传统节日 +德昂族浇花节,民俗,云南省德宏傣族景颇族自治州,德昂族传统节日 +阿昌族阿露窝罗节,民俗,云南省德宏傣族景颇族自治州,阿昌族传统节日 +普米族转山会,民俗,云南省怒江傈僳族自治州,普米族传统节日 +怒族仙女节,民俗,云南省怒江傈僳族自治州,怒族传统节日 +独龙族卡雀哇节,民俗,云南省怒江傈僳族自治州,独龙族传统节日 +鄂伦春族古伦木沓节,民俗,黑龙江省,鄂伦春族传统节日 +鄂温克族瑟宾节,民俗,内蒙古自治区,鄂温克族传统节日 +达斡尔族库木勒节,民俗,黑龙江省,达斡尔族传统节日 +蒙古族那达慕,民俗,内蒙古自治区,蒙古族传统节日 +朝鲜族农乐舞,民俗,吉林省延边朝鲜族自治州,朝鲜族传统舞蹈 +满族颁金节,民俗,辽宁省,满族传统节日 +锡伯族西迁节,民俗,新疆维吾尔自治区,锡伯族传统节日 +东乡族盖碗茶,民俗,甘肃省,东乡族传统习俗 +保安族腰刀锻制技艺,传统技艺,甘肃省积石山保安族东乡族撒拉族自治县,保安族传统手工艺 +裕固族服饰,民俗,甘肃省,裕固族传统服饰 +土族盘绣,传统美术,青海省互助土族自治县,土族传统刺绣 +撒拉族婚礼,民俗,青海省循化撒拉族自治县,撒拉族传统婚俗 +俄罗斯族巴斯克节,民俗,新疆维吾尔自治区,俄罗斯族传统节日 +塔吉克族引水节和播种节,民俗,新疆维吾尔自治区,塔吉克族传统节日 +塔塔尔族撒班节,民俗,新疆维吾尔自治区,塔塔尔族传统节日 +柯尔克孜族库姆孜艺术,传统音乐,新疆维吾尔自治区,柯尔克孜族传统音乐 +哈萨克族阿依特斯,传统音乐,新疆维吾尔自治区,哈萨克族传统音乐 +乌兹别克族埃希来、叶来,传统音乐,新疆维吾尔自治区,乌兹别克族传统音乐 +维吾尔族木卡姆艺术,传统音乐,新疆维吾尔自治区,维吾尔族传统音乐 +维吾尔族麦西热甫,民俗,新疆维吾尔自治区,维吾尔族传统民俗活动 +蒙古族四胡音乐,传统音乐,内蒙古自治区,蒙古族传统乐器 +蒙古族马头琴音乐,传统音乐,内蒙古自治区,蒙古族传统乐器 +蒙古族搏克,传统体育、游艺与杂技,内蒙古自治区,蒙古族传统体育 +蒙古族象棋,传统体育、游艺与杂技,内蒙古自治区,蒙古族传统棋类 +蒙古族勒勒车制作技艺,传统技艺,内蒙古自治区,蒙古族传统交通工具制作 +蒙古族服饰,民俗,内蒙古自治区,蒙古族传统服饰 +蒙古族马具制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族驼具制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族毡绣,传统美术,内蒙古自治区,蒙古族传统刺绣 +蒙古族刺绣,传统美术,内蒙古自治区,蒙古族传统刺绣 +蒙古族皮画,传统美术,内蒙古自治区,蒙古族传统绘画 +蒙古族银器制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族铜器制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族奶制品制作技艺,传统技艺,内蒙古自治区,蒙古族传统食品制作 +蒙古族肉食制作技艺,传统技艺,内蒙古自治区,蒙古族传统食品制作 +蒙古族酒具制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族弓箭制作技艺,传统技艺,内蒙古自治区,蒙古族传统武器制作 +蒙古族马鞭制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马鞍制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马镫制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马笼头制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马嚼子制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马汗垫制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马护腿制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马尾绳制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马刷制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马梳制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马镫带制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马肚带制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马鞍垫制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马鞍桥制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马鞍翅制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马鞍座制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马鞍皮制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马鞍木制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马鞍铁制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马鞍铜制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马鞍银制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 +蒙古族马鞍金制作技艺,传统技艺,内蒙古自治区,蒙古族传统手工艺 \ No newline at end of file diff --git a/w5/Circle.java b/w5/Circle.java new file mode 100644 index 0000000..1012107 --- /dev/null +++ b/w5/Circle.java @@ -0,0 +1,28 @@ +/** + * 圆:面积 = π * r² + */ +public class Circle extends Shape { + + private final double radius; + + public Circle(double radius) { + if (radius <= 0) { + throw new IllegalArgumentException("半径必须为正数"); + } + this.radius = radius; + } + + public double getRadius() { + return radius; + } + + @Override + public double getArea() { + return Math.PI * radius * radius; + } + + @Override + public void draw() { + System.out.println("绘制圆形"); + } +} diff --git a/w5/Computer.java b/w5/Computer.java new file mode 100644 index 0000000..d570a23 --- /dev/null +++ b/w5/Computer.java @@ -0,0 +1,19 @@ +/** + * 电脑:使用USB设备 + */ +public class Computer { + + /** + * 使用USB设备 + * @param usb USB设备实例 + */ + public void useUSB(USB usb) { + if (usb == null) { + System.out.println("USB设备为空,无法使用。"); + return; + } + usb.open(); + System.out.println("使用USB设备中..."); + usb.close(); + } +} \ No newline at end of file diff --git a/w5/Keyboard.java b/w5/Keyboard.java new file mode 100644 index 0000000..6a11b2f --- /dev/null +++ b/w5/Keyboard.java @@ -0,0 +1,15 @@ +/** + * 键盘:实现USB接口 + */ +public class Keyboard implements USB { + + @Override + public void open() { + System.out.println("键盘已连接"); + } + + @Override + public void close() { + System.out.println("键盘已断开"); + } +} \ No newline at end of file diff --git a/w5/Mouse.java b/w5/Mouse.java new file mode 100644 index 0000000..da54102 --- /dev/null +++ b/w5/Mouse.java @@ -0,0 +1,15 @@ +/** + * 鼠标:实现USB接口 + */ +public class Mouse implements USB { + + @Override + public void open() { + System.out.println("鼠标已连接"); + } + + @Override + public void close() { + System.out.println("鼠标已断开"); + } +} \ No newline at end of file diff --git a/w5/Person.java b/w5/Person.java new file mode 100644 index 0000000..5206f99 --- /dev/null +++ b/w5/Person.java @@ -0,0 +1,56 @@ +/** + * 人员抽象基类:包含人员的基本属性 + */ +public class Person { + + private String name; + private String id; + + /** + * 无参构造方法 + */ + public Person() { + } + + /** + * 有参构造方法 + * @param name 姓名 + * @param id 身份证号 + */ + public Person(String name, String id) { + this.name = name; + this.id = id; + } + + /** + * 获取姓名 + * @return 姓名 + */ + public String getName() { + return name; + } + + /** + * 设置姓名 + * @param name 姓名 + */ + public void setName(String name) { + this.name = name; + } + + /** + * 获取身份证号 + * @return 身份证号 + */ + public String getId() { + return id; + } + + /** + * 设置身份证号 + * @param id 身份证号 + */ + public void setId(String id) { + this.id = id; + } +} \ No newline at end of file diff --git a/w5/Rectangle.java b/w5/Rectangle.java new file mode 100644 index 0000000..7683a4a --- /dev/null +++ b/w5/Rectangle.java @@ -0,0 +1,34 @@ +/** + * 矩形:面积 = 宽 * 高 + */ +public class Rectangle extends Shape { + + private final double width; + private final double height; + + public Rectangle(double width, double height) { + if (width <= 0 || height <= 0) { + throw new IllegalArgumentException("宽和高必须为正数"); + } + this.width = width; + this.height = height; + } + + public double getWidth() { + return width; + } + + public double getHeight() { + return height; + } + + @Override + public double getArea() { + return width * height; + } + + @Override + public void draw() { + System.out.println("绘制矩形"); + } +} diff --git a/w5/Shape.java b/w5/Shape.java new file mode 100644 index 0000000..c004de6 --- /dev/null +++ b/w5/Shape.java @@ -0,0 +1,17 @@ +/** + * 图形抽象基类:统一多态入口,具体面积由子类实现。 + */ +public abstract class Shape { + + /** + * @return 图形面积(具体单位由子类语义决定,如平方厘米) + */ + public abstract double getArea(); + + /** + * 绘制图形 + */ + public void draw() { + System.out.println("绘制图形"); + } +} diff --git a/w5/ShapeCalculatorDemo.java b/w5/ShapeCalculatorDemo.java new file mode 100644 index 0000000..b8b8c0a --- /dev/null +++ b/w5/ShapeCalculatorDemo.java @@ -0,0 +1,57 @@ +/** + * 演示:多态 —— 同一 {@link ShapeUtil#printArea(Shape)} 处理圆、矩形、三角形。 + * 运行:javac *.java 后执行 java ShapeCalculatorDemo + */ +public class ShapeCalculatorDemo { + + public static void main(String[] args) { + Shape circle = new Circle(3.0); + Shape rectangle = new Rectangle(4.0, 5.0); + + System.out.println("—— 圆 ——"); + ShapeUtil.printArea(circle); + + System.out.println("—— 矩形 ——"); + ShapeUtil.printArea(rectangle); + + System.out.println("—— 多态数组统一处理 ——"); + Shape[] shapes = {circle, rectangle}; + for (Shape s : shapes) { + ShapeUtil.printArea(s); + } + + // 测试drawShape方法 + System.out.println("\n—— 测试绘制图形 ——"); + ShapeUtil.drawShape(circle); + ShapeUtil.drawShape(rectangle); + + // 测试Computer和USB设备 + System.out.println("\n—— 测试Computer和USB设备 ——"); + Computer computer = new Computer(); + Mouse mouse = new Mouse(); + Keyboard keyboard = new Keyboard(); + + System.out.println("使用鼠标:"); + computer.useUSB(mouse); + + System.out.println("\n使用键盘:"); + computer.useUSB(keyboard); + + // 测试学生管理系统 + System.out.println("\n—— 测试学生管理系统 ——"); + StudentManagementSystem sms = new StudentManagementSystem(); + + // 创建学生对象 + Student student = new Student("张三", "110101200001011234", "2024001", "计算机科学与技术"); + // 创建教师对象 + Teacher teacher = new Teacher("李四", "110101198001011234", "T2024001", "Java程序设计"); + + // 添加学生 + System.out.println("添加学生:"); + sms.addPerson(student); + + // 添加教师 + System.out.println("添加教师:"); + sms.addPerson(teacher); + } +} diff --git a/w5/ShapeUtil.java b/w5/ShapeUtil.java new file mode 100644 index 0000000..a5cd706 --- /dev/null +++ b/w5/ShapeUtil.java @@ -0,0 +1,35 @@ +/** + * 图形工具类:通过多态统一打印任意 {@link Shape} 的面积。 + */ +public final class ShapeUtil { + + private ShapeUtil() { + // 工具类禁止实例化 + } + + /** + * 打印给定图形的面积(保留两位小数,便于实验输出阅读)。 + * + * @param shape 任意 {@link Shape} 子类实例,可为 null(将给出提示) + */ + public static void printArea(Shape shape) { + if (shape == null) { + System.out.println("图形引用为空,无法计算面积。"); + return; + } + System.out.printf("该图形的面积为:%.2f%n", shape.getArea()); + } + + /** + * 绘制给定图形。 + * + * @param s 任意 {@link Shape} 子类实例,可为 null(将给出提示) + */ + public static void drawShape(Shape s) { + if (s == null) { + System.out.println("图形引用为空,无法绘制。"); + return; + } + s.draw(); + } +} diff --git a/w5/Student.java b/w5/Student.java new file mode 100644 index 0000000..cb7a595 --- /dev/null +++ b/w5/Student.java @@ -0,0 +1,59 @@ +/** + * 学生:继承Person类,添加学生特有属性 + */ +public class Student extends Person { + + private String studentId; // 学号 + private String major; // 专业 + + /** + * 无参构造方法 + */ + public Student() { + } + + /** + * 有参构造方法 + * @param name 姓名 + * @param id 身份证号 + * @param studentId 学号 + * @param major 专业 + */ + public Student(String name, String id, String studentId, String major) { + super(name, id); + this.studentId = studentId; + this.major = major; + } + + /** + * 获取学号 + * @return 学号 + */ + public String getStudentId() { + return studentId; + } + + /** + * 设置学号 + * @param studentId 学号 + */ + public void setStudentId(String studentId) { + this.studentId = studentId; + } + + /** + * 获取专业 + * @return 专业 + */ + public String getMajor() { + return major; + } + + /** + * 设置专业 + * @param major 专业 + */ + public void setMajor(String major) { + this.major = major; + } +} \ No newline at end of file diff --git a/w5/StudentManagementSystem.java b/w5/StudentManagementSystem.java new file mode 100644 index 0000000..323ba44 --- /dev/null +++ b/w5/StudentManagementSystem.java @@ -0,0 +1,38 @@ +/** + * 学生管理系统:使用多态重构添加人员的方法 + */ +public class StudentManagementSystem { + + /** + * 添加人员(使用多态,统一处理学生和教师) + * @param p 人员对象(Student或Teacher) + */ + public void addPerson(Person p) { + if (p == null) { + System.out.println("人员对象为空,无法添加。"); + return; + } + + // 通用信息 + System.out.println("添加人员信息:"); + System.out.println("姓名:" + p.getName()); + System.out.println("身份证号:" + p.getId()); + + // 使用instanceof判断具体类型,处理特有属性 + if (p instanceof Student) { + Student student = (Student) p; + System.out.println("类型:学生"); + System.out.println("学号:" + student.getStudentId()); + System.out.println("专业:" + student.getMajor()); + } else if (p instanceof Teacher) { + Teacher teacher = (Teacher) p; + System.out.println("类型:教师"); + System.out.println("教师编号:" + teacher.getTeacherId()); + System.out.println("教授科目:" + teacher.getSubject()); + } else { + System.out.println("类型:其他人员"); + } + + System.out.println("添加成功!\n"); + } +} \ No newline at end of file diff --git a/w5/Teacher.java b/w5/Teacher.java new file mode 100644 index 0000000..d1771fe --- /dev/null +++ b/w5/Teacher.java @@ -0,0 +1,59 @@ +/** + * 教师:继承Person类,添加教师特有属性 + */ +public class Teacher extends Person { + + private String teacherId; // 教师编号 + private String subject; // 教授科目 + + /** + * 无参构造方法 + */ + public Teacher() { + } + + /** + * 有参构造方法 + * @param name 姓名 + * @param id 身份证号 + * @param teacherId 教师编号 + * @param subject 教授科目 + */ + public Teacher(String name, String id, String teacherId, String subject) { + super(name, id); + this.teacherId = teacherId; + this.subject = subject; + } + + /** + * 获取教师编号 + * @return 教师编号 + */ + public String getTeacherId() { + return teacherId; + } + + /** + * 设置教师编号 + * @param teacherId 教师编号 + */ + public void setTeacherId(String teacherId) { + this.teacherId = teacherId; + } + + /** + * 获取教授科目 + * @return 教授科目 + */ + public String getSubject() { + return subject; + } + + /** + * 设置教授科目 + * @param subject 教授科目 + */ + public void setSubject(String subject) { + this.subject = subject; + } +} \ No newline at end of file diff --git a/w5/USB.java b/w5/USB.java new file mode 100644 index 0000000..8ec416c --- /dev/null +++ b/w5/USB.java @@ -0,0 +1,15 @@ +/** + * USB接口:定义USB设备的基本行为 + */ +public interface USB { + + /** + * 打开USB设备 + */ + void open(); + + /** + * 关闭USB设备 + */ + void close(); +} \ No newline at end of file diff --git a/w6/Animal.java b/w6/Animal.java new file mode 100644 index 0000000..3273550 --- /dev/null +++ b/w6/Animal.java @@ -0,0 +1,5 @@ +// 抽象类Animal,定义动物的基本行为 +public abstract class Animal { + // 抽象方法makeSound,子类必须实现 + public abstract void makeSound(); +} \ No newline at end of file diff --git a/w6/Cat.java b/w6/Cat.java new file mode 100644 index 0000000..100b289 --- /dev/null +++ b/w6/Cat.java @@ -0,0 +1,8 @@ +// Cat类,继承Animal抽象类 +public class Cat extends Animal { + // 重写makeSound方法,实现猫的叫声 + @Override + public void makeSound() { + System.out.println("Cat meows: Meow! Meow!"); + } +} \ No newline at end of file diff --git a/w6/Dog.java b/w6/Dog.java new file mode 100644 index 0000000..c7150ae --- /dev/null +++ b/w6/Dog.java @@ -0,0 +1,14 @@ +// Dog类,继承Animal抽象类并实现Swimmable接口 +public class Dog extends Animal implements Swimmable { + // 重写makeSound方法,实现狗的叫声 + @Override + public void makeSound() { + System.out.println("Dog barks: Woof! Woof!"); + } + + // 实现swim方法,定义狗的游泳行为 + @Override + public void swim() { + System.out.println("Dog is swimming"); + } +} \ No newline at end of file diff --git a/w6/TestAnimal.java b/w6/TestAnimal.java new file mode 100644 index 0000000..fe86109 --- /dev/null +++ b/w6/TestAnimal.java @@ -0,0 +1,21 @@ +// 测试类,用于验证多态和接口的使用 +public class TestAnimal { + public static void main(String[] args) { + // 使用多态创建Animal类型的引用,指向Dog和Cat对象 + Animal dog = new Dog(); + Animal cat = new Cat(); + + // 调用makeSound方法,多态会根据实际对象类型调用相应的方法 + System.out.println("Testing makeSound() method:"); + dog.makeSound(); // 实际调用Dog类的makeSound方法 + cat.makeSound(); // 实际调用Cat类的makeSound方法 + + // 调用Dog对象的swim方法 + System.out.println("\nTesting swim() method:"); + // 需要将Animal类型的引用转换为Swimmable接口类型,然后调用swim方法 + ((Swimmable) dog).swim(); + + // 注意:Cat类没有实现Swimmable接口,所以不能调用swim方法 + // ((Swimmable) cat).swim(); // 这行代码会编译错误 + } +} \ No newline at end of file diff --git a/w7/ScoreCalculator.java b/w7/ScoreCalculator.java new file mode 100644 index 0000000..645a174 --- /dev/null +++ b/w7/ScoreCalculator.java @@ -0,0 +1,41 @@ +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; +import java.io.FileNotFoundException; + +public class ScoreCalculator { + public static void main(String[] args) { + String filePath = "scores.txt"; + int sum = 0; + int count = 0; + + try (BufferedReader br = new BufferedReader(new FileReader(filePath))) { + String line; + while ((line = br.readLine()) != null) { + try { + int score = Integer.parseInt(line.trim()); + sum += score; + count++; + } catch (NumberFormatException e) { + System.out.println("警告:跳过无效的数字格式 - \"" + line + "\""); + } + } + + if (count == 0) { + System.out.println("未找到有效的成绩数据"); + } else { + double average = (double) sum / count; + System.out.println("========== 成绩统计结果 =========="); + System.out.println("有效成绩数量: " + count); + System.out.println("总成绩: " + sum); + System.out.println("平均分: " + String.format("%.2f", average)); + } + + } catch (FileNotFoundException e) { + System.out.println("错误:文件不存在 - " + filePath); + System.out.println("请确保 scores.txt 文件位于程序运行目录下"); + } catch (IOException e) { + System.out.println("错误:文件读取失败 - " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/w7/Test.java b/w7/Test.java new file mode 100644 index 0000000..9daf87e --- /dev/null +++ b/w7/Test.java @@ -0,0 +1,5 @@ +public class Test { + public static void main(String[] args) { + System.out.println("Hello World!"); + } +} \ No newline at end of file diff --git a/w7/scores.txt b/w7/scores.txt new file mode 100644 index 0000000..7f1ec9c --- /dev/null +++ b/w7/scores.txt @@ -0,0 +1,9 @@ +85 +92 +78 +90 +88 +abc +95 +xyz +80 \ No newline at end of file diff --git a/w8/AI协同学习和思考题.docx b/w8/AI协同学习和思考题.docx new file mode 100644 index 0000000000000000000000000000000000000000..b9163b22f50be74aee120b3d50834af535f60660 GIT binary patch literal 13579 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 literal 0 HcmV?d00001 diff --git a/w8/GenericHomework.java b/w8/GenericHomework.java new file mode 100644 index 0000000..cd8db1b --- /dev/null +++ b/w8/GenericHomework.java @@ -0,0 +1,171 @@ +import java.util.HashMap; +import java.util.Map; + +/** + * ============================================ + * 任务一:泛型类 Pair + * - 包含构造方法、getter/setter + * - swap() 方法用于交换 K 和 V 的值 + * ============================================ + */ +class Pair { + private K key; + private V value; + + public Pair() { + } + + public Pair(K key, V value) { + this.key = key; + this.value = value; + } + + public K getKey() { + return key; + } + + public void setKey(K key) { + this.key = key; + } + + public V getValue() { + return value; + } + + public void setValue(V value) { + this.value = value; + } + + /** + * 交换 key 和 value 的值 + */ + public void swap() { + K temp = this.key; + this.key = (K) this.value; + this.value = (V) temp; + } + + @Override + public String toString() { + return "Pair{key=" + key + ", value=" + value + "}"; + } + + public static void main(String[] args) { + System.out.println("========== Pair 测试 =========="); + + Pair pair = new Pair<>("Hello", 100); + System.out.println("交换前: " + pair); + pair.swap(); + System.out.println("交换后: " + pair); + + Pair pair2 = new Pair<>(3.14, "PI"); + System.out.println("交换前: " + pair2); + pair2.swap(); + System.out.println("交换后: " + pair2); + } +} + +/** + * ============================================ + * 任务二:泛型缓存类 Cache + * - put(K key, V value): 添加缓存 + * - get(K key): 获取缓存 + * - remove(K key): 删除缓存 + * - clear(): 清空缓存 + * ============================================ + */ +class Cache { + private final Map cache; + + public Cache() { + this.cache = new HashMap<>(); + } + + public void put(K key, V value) { + if (key == null) { + throw new IllegalArgumentException("key不能为null"); + } + cache.put(key, value); + } + + public V get(K key) { + if (key == null) { + return null; + } + return cache.get(key); + } + + public V remove(K key) { + if (key == null) { + return null; + } + return cache.remove(key); + } + + public void clear() { + cache.clear(); + } + + public int size() { + return cache.size(); + } + + public boolean containsKey(K key) { + if (key == null) { + return false; + } + return cache.containsKey(key); + } + + @Override + public String toString() { + return "Cache{" + cache + "}"; + } + + public static void main(String[] args) { + System.out.println("========== Cache 测试 =========="); + Cache cache = new Cache<>(); + + cache.put("Java", 90); + cache.put("Python", 85); + cache.put("C++", 92); + System.out.println("添加后缓存: " + cache); + System.out.println("缓存大小: " + cache.size()); + + System.out.println("获取Java成绩: " + cache.get("Java")); + System.out.println("获取不存在的Key: " + cache.get("Go")); + + cache.put("Java", 95); + System.out.println("更新Java成绩后: " + cache); + + System.out.println("删除Python: " + cache.remove("Python")); + System.out.println("删除后缓存: " + cache); + + cache.clear(); + System.out.println("清空后缓存: " + cache); + + try { + cache.put(null, 100); + } catch (IllegalArgumentException e) { + System.out.println("null key测试: " + e.getMessage()); + } + } +} + +/** + * ============================================ + * 主类:运行测试 + * ============================================ + */ +public class GenericHomework { + public static void main(String[] args) { + System.out.println("【任务一】Pair 测试"); + System.out.println("========================"); + Pair.main(null); + System.out.println(); + + System.out.println("【任务二】Cache 测试"); + System.out.println("========================"); + Cache.main(null); + } +} \ No newline at end of file diff --git a/w9/ArchitectureAuditReport.java b/w9/ArchitectureAuditReport.java new file mode 100644 index 0000000..21d77a0 --- /dev/null +++ b/w9/ArchitectureAuditReport.java @@ -0,0 +1,113 @@ +package com.example.datacollect; + +/** + * ============================================================================= + * MVC 三层架构审计报告 + * ============================================================================= + * + * 一、审计目的 + * ---------- + * 检查当前项目的 MVC(Model/View/Controller)三层架构划分是否规范, + * 是否存在跨层直接调用等越权行为。 + * + * + * 二、当前项目结构分析 + * ------------------- + * + * Model 层: + * - com.example.datacollect.model.Article + * + * View 层: + * - com.example.datacollect.view.ConsoleView + * + * Controller 层: + * - com.example.datacollect.controller.CrawlerController + * + * Command 层(可视为 Controller 的扩展): + * - HelpCommand, ListCommand, CrawlCommand, ExitCommand, HistoryCommand + * + * + * 三、发现的问题 + * ------------- + * + * 【问题1】Controller 直接持有 Model 列表引用 ⚠️ + * 位置:CrawlerController.java 第17行 + * 代码:private final List
articles; + * + * 风险: + * - Controller 直接操作 Model 数据,违反单一职责 + * - 多个 Command 都可以直接修改 articles 列表 + * - 数据修改入口分散,难以追踪 + * + * 修改建议: + * - 引入 Service 层(如 ArticleService)专门管理 Article 数据 + * - Controller 只持有 Service 引用,Command 通过 Controller 间接访问 + * + * + * 【问题2】Command 直接持有 View 引用 ⚠️ + * 位置:所有 Command 实现类(如 CrawlCommand.java 第10行) + * 代码:private final ConsoleView view; + * + * 风险: + * - Command 越过 Controller 直接与 View 交互 + * - Command 承担了部分 Controller 职责 + * + * 修改建议: + * - Command 只负责解析命令和调用 Controller + * - View 引用统一由 Controller 管理 + * + * + * 【问题3】List
共享引用风险 ⚠️ + * 位置:CrawlerController.java 第17行 + * 代码:private final List
articles; + * + * 风险: + * - articles 作为可变共享状态被多个 Command 操作 + * - 多线程环境下可能导致数据不一致 + * + * 修改建议: + * - 使用不可变列表或返回副本 + * - 添加线程安全保护(如 CopyOnWriteArrayList) + * + * + * 【问题4】View 直接遍历 Model 数据 + * 位置:ConsoleView.java display 方法 + * + * 风险: + * - View 层直接访问 Model 数据结构 + * + * 修改建议: + * - Controller 将需要显示的数据封装为 DTO + * + * + * 四、架构修改建议 + * --------------- + * + * 【推荐架构】 + * View -> Controller -> Service -> DAO -> Model + * + * 【具体修改】 + * 1. 新增 ArticleService.java 统一管理 Article 数据 + * 2. 修改 CrawlerController 持有 ArticleService 而非 List
+ * 3. Command 只调用 Controller 方法,不直接操作列表 + * + * + * 五、审计清单 + * ----------- + * □ Controller 是否直接持有 Model 数据? -> 是,需要引入 Service + * □ Command 是否直接操作 View? -> 是,需要通过 Controller + * □ 共享 List 是否线程安全? -> 否,需要保护 + * □ View 是否直接访问 Model? -> 部分存在,需要 DTO + * + * + * 六、总结 + * ------- + * 当前项目存在以下越权行为: + * 1. Controller 直接持有 Model 数据 + * 2. Command 直接操作 View 和 Model + * 3. 共享数据缺乏线程安全保护 + * + * 建议逐步引入 Service 层,解耦数据管理和视图渲染。 + * + * ============================================================================= + */ \ No newline at end of file diff --git a/w9/Article.java b/w9/Article.java new file mode 100644 index 0000000..83a089a --- /dev/null +++ b/w9/Article.java @@ -0,0 +1,104 @@ +package com.example.datacollect.model; + +import java.time.LocalDate; +import java.util.Objects; + +/** + * 文章实体类 + * 用于存储文章的相关信息 + */ +public class Article { + + private String title; + private String url; + private String content; + private String author; + private LocalDate publishDate; + + /** + * 无参构造方法 + */ + public Article() { + } + + /** + * 全参构造方法 + * @param title 文章标题 + * @param url 文章URL + * @param content 文章内容 + * @param author 作者 + * @param publishDate 发布日期 + */ + public Article(String title, String url, String content, String author, LocalDate publishDate) { + this.title = title; + this.url = url; + this.content = content; + this.author = author; + this.publishDate = publishDate; + } + + // Getter 和 Setter 方法 + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public String getAuthor() { + return author; + } + + public void setAuthor(String author) { + this.author = author; + } + + public LocalDate getPublishDate() { + return publishDate; + } + + public void setPublishDate(LocalDate publishDate) { + this.publishDate = publishDate; + } + + @Override + public String toString() { + return "Article{" + + "title='" + title + '\'' + + ", url='" + url + '\'' + + ", content='" + content + '\'' + + ", author='" + author + '\'' + + ", publishDate=" + publishDate + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Article article = (Article) o; + return Objects.equals(url, article.url); + } + + @Override + public int hashCode() { + return Objects.hash(url); + } +} \ No newline at end of file diff --git a/w9/CommandAlias.java b/w9/CommandAlias.java new file mode 100644 index 0000000..fd9a9d5 --- /dev/null +++ b/w9/CommandAlias.java @@ -0,0 +1,129 @@ +package com.example.datacollect.command; + +import java.util.HashMap; +import java.util.Map; +import java.util.Scanner; + +/** + * 命令别名管理器 + * 支持将长命令映射为短别名,方便用户输入 + */ +public class CommandAlias { + + private Map aliasMap; + + public CommandAlias() { + this.aliasMap = new HashMap<>(); + initDefaultAliases(); + } + + /** + * 初始化默认别名 + */ + private void initDefaultAliases() { + aliasMap.put("c", "crawl"); + aliasMap.put("r", "run"); + aliasMap.put("s", "stop"); + aliasMap.put("h", "help"); + aliasMap.put("q", "quit"); + aliasMap.put("l", "list"); + aliasMap.put("a", "add"); + aliasMap.put("d", "delete"); + aliasMap.put("e", "edit"); + aliasMap.put("hist", "history"); + } + + /** + * 添加自定义别名 + * @param alias 别名 + * @param command 原命令 + */ + public void addAlias(String alias, String command) { + if (alias != null && command != null) { + aliasMap.put(alias.toLowerCase(), command.toLowerCase()); + } + } + + /** + * 移除别名 + * @param alias 要移除的别名 + */ + public void removeAlias(String alias) { + aliasMap.remove(alias.toLowerCase()); + } + + /** + * 将别名转换为原命令 + * @param input 用户输入 + * @return 原命令,如果输入不是别名则返回原输入 + */ + public String resolveCommand(String input) { + if (input == null) { + return null; + } + String trimmed = input.trim().toLowerCase(); + return aliasMap.getOrDefault(trimmed, input); + } + + /** + * 检查输入是否为别名 + * @param input 用户输入 + * @return 是否为别名 + */ + public boolean isAlias(String input) { + if (input == null) { + return false; + } + return aliasMap.containsKey(input.trim().toLowerCase()); + } + + /** + * 获取所有别名映射 + * @return 别名映射的副本 + */ + public Map getAllAliases() { + return new HashMap<>(aliasMap); + } + + /** + * 打印所有别名 + */ + public void printAliases() { + System.out.println("===== 命令别名列表 ====="); + for (Map.Entry entry : aliasMap.entrySet()) { + System.out.println(" " + entry.getKey() + " -> " + entry.getValue()); + } + System.out.println("========================"); + } + + /** + * 简单交互测试主方法 + */ + public static void main(String[] args) { + CommandAlias aliasManager = new CommandAlias(); + Scanner scanner = new Scanner(System.in); + + System.out.println("===== 命令别名测试 ====="); + aliasManager.printAliases(); + System.out.println("\n输入命令或别名测试(输入 exit 退出):"); + + while (true) { + System.out.print("\n> "); + String input = scanner.nextLine().trim(); + + if ("exit".equalsIgnoreCase(input)) { + break; + } + + String resolved = aliasManager.resolveCommand(input); + if (!resolved.equals(input)) { + System.out.println("别名 '" + input + "' -> 原命令: " + resolved); + } else { + System.out.println("原命令: " + resolved); + } + } + + System.out.println("测试结束"); + scanner.close(); + } +} \ No newline at end of file diff --git a/w9/HistoryCommand.java b/w9/HistoryCommand.java new file mode 100644 index 0000000..3dad508 --- /dev/null +++ b/w9/HistoryCommand.java @@ -0,0 +1,96 @@ +package com.example.datacollect.command; + +import java.util.ArrayList; +import java.util.List; +import java.util.Scanner; + +/** + * 命令历史记录管理器 + * 用于记录用户输入的所有命令,并提供查看历史的功能 + */ +public class HistoryCommand implements Command { + + private List commandHistory; + + public HistoryCommand() { + this.commandHistory = new ArrayList<>(); + } + + /** + * 添加命令到历史记录 + * @param command 用户输入的命令 + */ + public void addCommand(String command) { + if (command != null && !command.trim().isEmpty()) { + commandHistory.add(command); + } + } + + /** + * 打印所有历史命令 + */ + public void printHistory() { + if (commandHistory.isEmpty()) { + System.out.println("暂无命令历史记录"); + return; + } + System.out.println("===== 命令历史记录 ====="); + for (int i = 0; i < commandHistory.size(); i++) { + System.out.println((i + 1) + ". " + commandHistory.get(i)); + } + System.out.println("======================="); + } + + /** + * 获取命令历史列表 + * @return 命令历史列表的副本 + */ + public List getCommandHistory() { + return new ArrayList<>(commandHistory); + } + + /** + * 清空历史记录 + */ + public void clearHistory() { + commandHistory.clear(); + } + + @Override + public String getName() { + return "history"; + } + + @Override + public void execute(String[] args, List
articles) { + printHistory(); + } + + /** + * 简单交互测试主方法 + */ + public static void main(String[] args) { + HistoryCommand historyCommand = new HistoryCommand(); + Scanner scanner = new Scanner(System.in); + + System.out.println("===== 命令历史测试 ====="); + System.out.println("输入命令(输入 exit 退出,输入 history 查看历史):"); + + while (true) { + System.out.print("\n> "); + String input = scanner.nextLine().trim(); + + if ("exit".equalsIgnoreCase(input)) { + System.out.println("退出程序"); + break; + } else if ("history".equalsIgnoreCase(input)) { + historyCommand.printHistory(); + } else { + historyCommand.addCommand(input); + System.out.println("命令已记录: " + input); + } + } + + scanner.close(); + } +} \ No newline at end of file diff --git a/w9/UIConstants.java b/w9/UIConstants.java new file mode 100644 index 0000000..f530f27 --- /dev/null +++ b/w9/UIConstants.java @@ -0,0 +1,81 @@ +package com.example.datacollect.util; + +/** + * UI 颜色常量类 + * + * 【修改位置提示】 + * 只需修改 THEME_MODE 常量即可切换亮色/暗色主题 + * THEME_MODE = false -> 亮色主题 + * THEME_MODE = true -> 暗色主题 + */ +public class UIConstants { + + /** 主题模式开关:false = 亮色主题,true = 暗色主题 */ + public static final boolean THEME_MODE = true; + + /** 亮色主题 - 背景色 */ + public static final String LIGHT_BG_COLOR = "#FFFFFF"; + /** 亮色主题 - 前景色(文字) */ + public static final String LIGHT_FG_COLOR = "#000000"; + /** 亮色主题 - 按钮色 */ + public static final String LIGHT_BUTTON_COLOR = "#007BFF"; + + /** 暗色主题 - 背景色 */ + public static final String DARK_BG_COLOR = "#1E1E1E"; + /** 暗色主题 - 前景色(文字) */ + public static final String DARK_FG_COLOR = "#E0E0E0"; + /** 暗色主题 - 按钮色 */ + public static final String DARK_BUTTON_COLOR = "#0D6EFD"; + + /** 根据主题模式获取背景色 */ + public static String getBackgroundColor() { + return THEME_MODE ? DARK_BG_COLOR : LIGHT_BG_COLOR; + } + + /** 根据主题模式获取前景色 */ + public static String getForegroundColor() { + return THEME_MODE ? DARK_FG_COLOR : LIGHT_FG_COLOR; + } + + /** 根据主题模式获取按钮色 */ + public static String getButtonColor() { + return THEME_MODE ? DARK_BUTTON_COLOR : LIGHT_BUTTON_COLOR; + } + + /** 打印当前主题配置 */ + public static void printCurrentTheme() { + System.out.println("===== UI 主题配置 ====="); + System.out.println("当前模式: " + (THEME_MODE ? "暗色主题" : "亮色主题")); + System.out.println("背景色: " + getBackgroundColor()); + System.out.println("前景色: " + getForegroundColor()); + System.out.println("按钮色: " + getButtonColor()); + System.out.println("======================"); + } + + public static void main(String[] args) { + printCurrentTheme(); + } +} + + +/* + * ============================================================================= + * 使用说明 + * ============================================================================= + * + * 1. 找到 UIConstants.java 文件 + * 路径:com.example.datacollect.util.UIConstants + * + * 2. 修改主题开关(只需改这一行): + * public static final boolean THEME_MODE = true; // true = 暗色, false = 亮色 + * + * 3. 所有 UI 颜色会自动切换: + * - 亮色模式: 白色背景 + 黑色文字 + 蓝色按钮 + * - 暗色模式: 深灰背景 + 浅灰文字 + 亮蓝按钮 + * + * 4. 在其他类中使用: + * String bgColor = UIConstants.getBackgroundColor(); + * String fgColor = UIConstants.getForegroundColor(); + * + * ============================================================================= + */ \ No newline at end of file diff --git a/w9/UrlValidator.java b/w9/UrlValidator.java new file mode 100644 index 0000000..6bb008f --- /dev/null +++ b/w9/UrlValidator.java @@ -0,0 +1,134 @@ +package com.example.datacollect.util; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.regex.Pattern; + +/** + * URL 格式验证工具类 + * 用于判断输入的字符串是否是合法的 HTTP/HTTPS URL + */ +public class UrlValidator { + + private static final String HTTP_PROTOCOL = "http"; + private static final String HTTPS_PROTOCOL = "https"; + + private static final Pattern DOMAIN_PATTERN = Pattern.compile( + "^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$" + ); + + /** + * 验证 URL 是否合法 + * @param url 要验证的 URL 字符串 + * @return 是否为合法的 HTTP/HTTPS URL + */ + public static boolean isValidUrl(String url) { + if (url == null || url.trim().isEmpty()) { + return false; + } + + try { + URI uri = new URI(url); + + String scheme = uri.getScheme(); + if (scheme == null) { + return false; + } + + String schemeLower = scheme.toLowerCase(); + if (!HTTP_PROTOCOL.equals(schemeLower) && !HTTPS_PROTOCOL.equals(schemeLower)) { + return false; + } + + String host = uri.getHost(); + if (host == null || host.isEmpty()) { + return false; + } + + if (!DOMAIN_PATTERN.matcher(host).matches()) { + return false; + } + + return true; + + } catch (URISyntaxException e) { + return false; + } + } + + /** + * 验证 URL 并返回详细信息 + * @param url 要验证的 URL 字符串 + * @return 验证结果描述 + */ + public static String validateWithMessage(String url) { + if (url == null || url.trim().isEmpty()) { + return "无效:URL 不能为空"; + } + + try { + URI uri = new URI(url); + + String scheme = uri.getScheme(); + if (scheme == null) { + return "无效:缺少协议 scheme(如 http://)"; + } + + String schemeLower = scheme.toLowerCase(); + if (!HTTP_PROTOCOL.equals(schemeLower) && !HTTPS_PROTOCOL.equals(schemeLower)) { + return "无效:协议必须是 http 或 https,当前为:" + scheme; + } + + String host = uri.getHost(); + if (host == null || host.isEmpty()) { + return "无效:缺少主机名"; + } + + return "有效 URL:" + url; + + } catch (URISyntaxException e) { + return "无效:URL 格式错误 - " + e.getMessage(); + } + } + + /** + * 获取 URL 的协议 + * @param url URL 字符串 + * @return 协议名称,如果无效返回 null + */ + public static String getProtocol(String url) { + try { + URI uri = new URI(url); + return uri.getScheme(); + } catch (URISyntaxException e) { + return null; + } + } + + /** + * 测试主方法 + */ + public static void main(String[] args) { + String[] testUrls = { + "https://www.example.com", + "http://localhost:8080/api", + "https://github.com/user/repo", + "ftp://ftp.example.com/file", + "htp://invalid.protocol", + "not a url", + "", + "https://192.168.1.1:8080", + "https://sub.domain.example.com/path/to/page", + "javascript:alert(1)" + }; + + System.out.println("===== URL 格式验证测试 =====\n"); + + for (String url : testUrls) { + String result = validateWithMessage(url); + System.out.println("测试: " + (url.isEmpty() ? "(空字符串)" : url)); + System.out.println("结果: " + result); + System.out.println(); + } + } +} \ No newline at end of file diff --git a/w9/java-cli/.gitignore b/w9/java-cli/.gitignore new file mode 100644 index 0000000..0ebcf1a --- /dev/null +++ b/w9/java-cli/.gitignore @@ -0,0 +1,4 @@ +*.jar +*.jar +*.class +*.log \ No newline at end of file diff --git a/w9/java-cli/README.md b/w9/java-cli/README.md new file mode 100644 index 0000000..3ea02ec --- /dev/null +++ b/w9/java-cli/README.md @@ -0,0 +1,17 @@ +# DataCollect 教学项目 — 最小可运行版本 + +这是一个最小可用的 Java CLI 演示工程,目标:打印帮助信息以验证运行环境。 + +构建: +```bash +mvn -q package +``` + +运行(示例): +```bash +java -jar target/datacollect-cli-0.1.0-jar-with-dependencies.jar --help +``` + +项目结构(最小): +- `src/main/java/com/example/datacollect/Main.java` — CLI 入口,打印帮助 +- `pom.xml` — Maven 构建配置,生成可执行 jar diff --git a/w9/java-cli/pom.xml b/w9/java-cli/pom.xml new file mode 100644 index 0000000..01bc611 --- /dev/null +++ b/w9/java-cli/pom.xml @@ -0,0 +1,45 @@ + + 4.0.0 + com.example + datacollect-cli + 0.1.0 + + 11 + 11 + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + org.apache.maven.plugins + maven-assembly-plugin + 3.3.0 + + + + com.example.datacollect.Main + + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + + + + diff --git a/w9/java-cli/src/main/java/com/example/datacollect/Main.java b/w9/java-cli/src/main/java/com/example/datacollect/Main.java new file mode 100644 index 0000000..44d00aa --- /dev/null +++ b/w9/java-cli/src/main/java/com/example/datacollect/Main.java @@ -0,0 +1,21 @@ +package com.example.datacollect; + +import com.example.datacollect.controller.CrawlerController; +import com.example.datacollect.model.Article; +import com.example.datacollect.view.ConsoleView; +import java.util.ArrayList; +import java.util.List; + +public class Main { + + public static void main(String[] args) { + ConsoleView view = new ConsoleView(); + List
articles = new ArrayList<>(); + CrawlerController controller = new CrawlerController(view, articles); + + view.printSuccess("Welcome to CLI Crawler (w9_1)! Type help for commands."); + while (true) { + controller.handle(view.readLine()); + } + } +} diff --git a/w9/java-cli/src/main/java/com/example/datacollect/SharedReferenceRisksSummary.java b/w9/java-cli/src/main/java/com/example/datacollect/SharedReferenceRisksSummary.java new file mode 100644 index 0000000..4710147 --- /dev/null +++ b/w9/java-cli/src/main/java/com/example/datacollect/SharedReferenceRisksSummary.java @@ -0,0 +1,69 @@ +package com.example.datacollect; + +/** + * ============================================================================= + * List
共享引用风险小结 + * ============================================================================= + * + * 【问题描述】 + * + * 当多个组件或线程共享同一个 List
引用时,会带来以下风险: + * + * 1. 数据被意外修改 + * - 多个模块共享同一列表引用,一个模块的修改会影响其他模块 + * - 例如:ListCommand 删除了文章,CrawlCommand 随后遍历时数据已变化 + * - 导致数据不一致、遍历异常(ConcurrentModificationException) + * + * 2. 线程安全问题 + * - 当前 CrawlerController 直接持有 List
+ * - ArrayList 不是线程安全的,高并发下会出现问题 + * - 可能导致数据丢失、索引越界、数据损坏 + * + * 3. 作用域混淆 + * - 难以追踪数据在何时、何处被修改 + * - 调试困难,问题难以复现 + * - 代码可读性和可维护性降低 + * + * + * 【当前项目问题定位】 + * + * 位置:CrawlerController.java 第17行 + * 代码:private final List
articles; + * + * 风险:articles 被多个 Command 直接操作,违反单一职责原则 + * + * + * 【解决方案】 + * + * 1. 防御性复制 + * // 传入时复制 + * public void processArticles(List
input) { + * List
safeCopy = new ArrayList<>(input); + * } + * + * // 传出时复制 + * public List
getArticles() { + * return new ArrayList<>(internalList); + * } + * + * 2. 不可变列表 + * List
unmodifiable = Collections.unmodifiableList(articles); + * + * 3. 线程安全列表 + * private final List
articles = new CopyOnWriteArrayList<>(); + * + * 4. 引入 Service 层 + * - 单一组件负责 List 的管理 + * - 其他组件通过 Service 接口访问 + * - 遵循单一职责原则 + * + * + * 【总结】 + * + * 共享可变数据结构是许多复杂 bug 的根源。当前项目中, + * CrawlerController 直接持有 List
并传递给所有 Command, + * 这种设计存在数据被意外修改和线程安全风险。建议引入 Service 层 + * 统一管理数据访问,使用防御性复制或线程安全列表提高代码健壮性。 + * + * ============================================================================= + */ \ No newline at end of file diff --git a/w9/java-cli/src/main/java/com/example/datacollect/command/Command.java b/w9/java-cli/src/main/java/com/example/datacollect/command/Command.java new file mode 100644 index 0000000..24e59a6 --- /dev/null +++ b/w9/java-cli/src/main/java/com/example/datacollect/command/Command.java @@ -0,0 +1,9 @@ +package com.example.datacollect.command; + +import com.example.datacollect.model.Article; +import java.util.List; + +public interface Command { + String getName(); + void execute(String[] args, List
articles); +} diff --git a/w9/java-cli/src/main/java/com/example/datacollect/command/CrawlCommand.java b/w9/java-cli/src/main/java/com/example/datacollect/command/CrawlCommand.java new file mode 100644 index 0000000..45baea1 --- /dev/null +++ b/w9/java-cli/src/main/java/com/example/datacollect/command/CrawlCommand.java @@ -0,0 +1,27 @@ +package com.example.datacollect.command; + +import com.example.datacollect.model.Article; +import com.example.datacollect.view.ConsoleView; +import java.util.List; + +public class CrawlCommand implements Command { + private final ConsoleView view; + + public CrawlCommand(ConsoleView view) { + this.view = view; + } + + @Override + public String getName() { + return "crawl"; + } + + @Override + public void execute(String[] args, List
articles) { + if (args.length < 2) { + view.printError("Usage: crawl "); + return; + } + view.printInfo("Stub: would crawl " + args[1]); + } +} diff --git a/w9/java-cli/src/main/java/com/example/datacollect/command/ExitCommand.java b/w9/java-cli/src/main/java/com/example/datacollect/command/ExitCommand.java new file mode 100644 index 0000000..6946d85 --- /dev/null +++ b/w9/java-cli/src/main/java/com/example/datacollect/command/ExitCommand.java @@ -0,0 +1,24 @@ +package com.example.datacollect.command; + +import com.example.datacollect.model.Article; +import com.example.datacollect.view.ConsoleView; +import java.util.List; + +public class ExitCommand implements Command { + private final ConsoleView view; + + public ExitCommand(ConsoleView view) { + this.view = view; + } + + @Override + public String getName() { + return "exit"; + } + + @Override + public void execute(String[] args, List
articles) { + view.printSuccess("Bye!"); + System.exit(0); + } +} diff --git a/w9/java-cli/src/main/java/com/example/datacollect/command/HelpCommand.java b/w9/java-cli/src/main/java/com/example/datacollect/command/HelpCommand.java new file mode 100644 index 0000000..4fde32f --- /dev/null +++ b/w9/java-cli/src/main/java/com/example/datacollect/command/HelpCommand.java @@ -0,0 +1,23 @@ +package com.example.datacollect.command; + +import com.example.datacollect.model.Article; +import com.example.datacollect.view.ConsoleView; +import java.util.List; + +public class HelpCommand implements Command { + private final ConsoleView view; + + public HelpCommand(ConsoleView view) { + this.view = view; + } + + @Override + public String getName() { + return "help"; + } + + @Override + public void execute(String[] args, List
articles) { + view.printInfo("Commands: crawl , list, help, exit"); + } +} diff --git a/w9/java-cli/src/main/java/com/example/datacollect/command/ListCommand.java b/w9/java-cli/src/main/java/com/example/datacollect/command/ListCommand.java new file mode 100644 index 0000000..8eba2cd --- /dev/null +++ b/w9/java-cli/src/main/java/com/example/datacollect/command/ListCommand.java @@ -0,0 +1,23 @@ +package com.example.datacollect.command; + +import com.example.datacollect.model.Article; +import com.example.datacollect.view.ConsoleView; +import java.util.List; + +public class ListCommand implements Command { + private final ConsoleView view; + + public ListCommand(ConsoleView view) { + this.view = view; + } + + @Override + public String getName() { + return "list"; + } + + @Override + public void execute(String[] args, List
articles) { + view.display(articles); + } +} diff --git a/w9/java-cli/src/main/java/com/example/datacollect/controller/CrawlerController.java b/w9/java-cli/src/main/java/com/example/datacollect/controller/CrawlerController.java new file mode 100644 index 0000000..ad03ef4 --- /dev/null +++ b/w9/java-cli/src/main/java/com/example/datacollect/controller/CrawlerController.java @@ -0,0 +1,47 @@ +package com.example.datacollect.controller; + +import com.example.datacollect.command.Command; +import com.example.datacollect.command.CrawlCommand; +import com.example.datacollect.command.ExitCommand; +import com.example.datacollect.command.HelpCommand; +import com.example.datacollect.command.ListCommand; +import com.example.datacollect.model.Article; +import com.example.datacollect.view.ConsoleView; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class CrawlerController { + private final Map commands = new HashMap<>(); + private final ConsoleView view; + private final List
articles; + + public CrawlerController(ConsoleView view, List
articles) { + this.view = view; + this.articles = articles; + register(new HelpCommand(view)); + register(new ListCommand(view)); + register(new CrawlCommand(view)); + register(new ExitCommand(view)); + } + + private void register(Command command) { + commands.put(command.getName(), command); + } + + public void handle(String input) { + String text = input == null ? "" : input.trim(); + if (text.isEmpty()) { + return; + } + + String[] args = text.split("\\s+"); + String cmdName = args[0].toLowerCase(); + Command command = commands.get(cmdName); + if (command == null) { + view.printError("Unknown command: " + cmdName); + return; + } + command.execute(args, articles); + } +} diff --git a/w9/java-cli/src/main/java/com/example/datacollect/view/ConsoleView.java b/w9/java-cli/src/main/java/com/example/datacollect/view/ConsoleView.java new file mode 100644 index 0000000..3c1d47a --- /dev/null +++ b/w9/java-cli/src/main/java/com/example/datacollect/view/ConsoleView.java @@ -0,0 +1,42 @@ +package com.example.datacollect.view; + +import com.example.datacollect.model.Article; +import java.util.List; +import java.util.Scanner; + +public class ConsoleView { + private static final String ANSI_RESET = "\u001B[0m"; + private static final String ANSI_GREEN = "\u001B[32m"; + private static final String ANSI_RED = "\u001B[31m"; + private static final String ANSI_BLUE = "\u001B[34m"; + + private final Scanner scanner = new Scanner(System.in); + + public String readLine() { + System.out.print("> "); + return scanner.nextLine(); + } + + public void printSuccess(String msg) { + System.out.println(ANSI_GREEN + msg + ANSI_RESET); + } + + public void printError(String msg) { + System.out.println(ANSI_RED + msg + ANSI_RESET); + } + + public void printInfo(String msg) { + System.out.println(ANSI_BLUE + msg + ANSI_RESET); + } + + public void display(List
articles) { + if (articles.isEmpty()) { + printInfo("暂无文章,请先执行 crawl。"); + return; + } + for (int i = 0; i < articles.size(); i++) { + Article a = articles.get(i); + System.out.println((i + 1) + ". " + a.getTitle() + " | " + a.getUrl()); + } + } +} diff --git a/w9/java-cli/target/W9工程架构 - 教案v3.md b/w9/java-cli/target/W9工程架构 - 教案v3.md new file mode 100644 index 0000000..09de868 --- /dev/null +++ b/w9/java-cli/target/W9工程架构 - 教案v3.md @@ -0,0 +1,758 @@ +--- + +# 教案:《高级程序设计》第9周——工程架构:从"写代码"到"造系统" + +| 项目 | 内容 | +|------|------| +| **课程名称** | 高级程序设计 | +| **周次** | 第9周 | +| **主题** | 工程架构——从"写代码"到"造系统" | +| **学时** | 2学时(90分钟) | +| **授课对象** | 具备Python基础、已完成Java面向对象特性学习的学生 | +| **教学环境** | JDK 17+、IntelliJ IDEA、Maven(模板) | +| **前情提要** | 本课程原计划使用JavaFX GUI,后根据教学反馈转向CLI + MVC + 爬虫工程化 | + +--- + +## 教学调整说明:为什么选择CLI而不是GUI? + +> **原计划**:JavaFX桌面应用 → **新计划**:CLI命令行应用 + +| 维度 | GUI (JavaFX) | CLI (命令行) | +|------|--------------|-------------| +| **学习重心** | 布局、控件、事件监听 | 架构、分层、命令路由 | +| **学生痛点** | "窗口点击"与后端能力无关 | 真正锻炼工程思维 | +| **AI辅助** | AI生成FXML,学生看不懂 | AI辅助重构架构 | +| **工程化** | 脱离真实后端开发场景 | 模拟真实服务器/大数据开发 | +| **核心转型** | "视觉装饰"优先 | "逻辑架构"优先 | + +**决策理由**: +1. **985学生需要的是工程思维**,不是拖控件 +2. **接口抽象**是弱项,CLI + MVC更能暴露这个问题 +3. **彩色终端**足够酷炫,且代码量可控 + +**更深层的教育价值**: +> 在GUI框架中,架构已被框架强制划定,学生只是"遵守规矩";而CLI世界里没有任何框架告诉你模型在哪、视图在哪——**当外部约束消失,内部的工程纪律才真正建立**。这正是本节课要传递的核心精神。 + +--- + +## 一、教学目标 + +| 目标维度 | 具体描述 | +|----------|----------| +| **知识掌握** | 理解MVC架构的职责划分及其演化脉络;掌握Maven项目结构与pom.xml基础;理解Command模式的路由原理。 | +| **工程实践** | 能搭建规范的Maven项目包结构;能实现基于Scanner的控制台交互;能用Command接口实现可扩展的命令路由;能识别架构中的"越权行为"。 | +| **思维转型** | 从"一个类写全部"转向"分层解耦";从"修改现有代码"转向"新增类实现功能";从"满足功能"转向"代码的工程洁癖"。 | +| **工具应用** | 利用AI辅助审查MVC职责越权;让AI扮演"架构审计师"检查分层是否清晰;理解AI生成代码中的架构缺陷。 | + +--- + +## 二、教学重点与难点 + +| 项目 | 内容 | 突破方法 | +|------|------|----------| +| **重点** | MVC三层职责划分、CLI交互实现、Command接口解耦、代码中的工程细节(常量、输出归属) | 以"新增命令需要改什么"为切入点,展示Command模式的优势;通过现场"代码找茬"强化细节意识 | +| **难点** | Controller不写业务逻辑、Command接口的多态实现、共享数据模型的设计缺陷识别 | 现场演示:增加一个命令只需新建类,无需修改Controller;暴露`List
`共享引用的问题并预告解决方案 | + +--- + +## 三、教学过程设计(90分钟) + +| 环节 | 时间 | 教学内容 | 师生活动 | AI协同点 | +|------|------|----------|----------|----------| +| **1. 痛点引入:从脚本到工程的鸿沟** | 10' | 展示"意大利面"式爬虫代码,演示改一处需要动全身 | **教师演示**:现场展示一段混乱代码,让学生找问题 | 用AI分析代码耦合度 | +| **2. CLI vs GUI:架构选择的思考** | 10' | 对比两种方案的优缺点,解释为什么CLI更适合培养工程思维 | **教师讲解**:用对比表格说明选择CLI的理由 | — | +| **3. MVC分层设计** | 20' | 讲解Model/View/Controller三层职责,用"餐厅类比"强化理解,随后批判类比局限性 | **教师讲解**:配合架构图讲解三层交互,引导学生寻找类比破绽 | 用AI生成MVC职责对照表 | +| **4. Command模式:可扩展的命令路由** | 15' | 引入Command接口,解释"一个命令就是一个类" | **类比**:Command像酒店的服务部门,Controller是前台 | 让AI解释Command模式的多态原理 | +| **5. Maven模板与环境** | 5' | 直接使用提供的Maven模板,讲解目录结构 | **教师演示**:解压模板 → IDEA打开 → 运行 | — | +| **6. 三层代码落地** | 20' | **Model**:Article实体
**View**:ConsoleView(ANSI常量)
**Command接口**+实现
**Controller**:Map路由 | **教师演示**:分步写出代码,刻意埋入1~2个"越权细节"让学生找茬 | 学生用AI做"架构审计" | +| **7. 架构反思与展望** | 5' | 指出当前`List
`共享引用的问题,预告W10策略模式与仓库层 | **师生互动**:你发现这个设计有什么风险? | 让AI分析共享可变状态的危害 | +| **8. 实践任务:空壳程序** | 5' | 搭建完整包结构,实现CLI循环 | 学生现场编码,教师巡视 | 完成后用AI检查包结构 | +| **9. 总结与过渡** | 5' | 本周实现了"骨架+命令可扩展",下周填入"灵魂"——解析器,并解决数据安全问题 | 总结Command模式优势,预告策略模式 | — | + +--- + +## 四、核心教学内容脚本 + +### 4.1 痛点引入:从脚本到工程的鸿沟(10分钟) + +**教师口播**: +> "同学们,前8周我们学的是Java语法,从变量到类,从继承到接口。但有一个问题:代码写完之后,怎么组织?" +> +> "来看这段代码——这是某个同学写的'爬虫',他一个人完成了一个'完整'的项目。" + +**展示"脚本式"代码**: +```java +public class Crawler { + public static void main(String[] args) { + System.out.print("请输入URL: "); + Scanner scanner = new Scanner(System.in); + String url = scanner.nextLine(); + + List titles = new ArrayList(); + try { + Document doc = Jsoup.connect(url).get(); + Elements elements = doc.select(".post-title"); + for (Element e : elements) { + String title = e.text(); + System.out.println("标题: " + title); + titles.add(title); + } + } catch (Exception ex) { + System.out.println("出错啦: " + ex.getMessage()); + } + } +} +``` + +**提问引导**: +1. "如果我想把标题保存到文件,要改哪里?" +2. "如果我想支持另一个网站,它的HTML结构不一样,要怎么办?" +3. "如果我想让输出变成彩色,要改哪里?" + +**痛点提炼**: +> "看到了吗?才60行代码,已经'牵一发而动全身'了。这就是一个'脚本'的宿命——功能全混在一起,改一个小需求,整个文件都要翻。" +> +> "这周我们要解决:**怎么让代码'改起来不疼'?**" + +--- + +### 4.2 CLI vs GUI:架构选择的思考(10分钟) + +**教师口播**: +> "既然要写一个'完整'的爬虫应用,我们有两个选择:图形界面(GUI)或命令行界面(CLI)。为什么我推荐CLI而不是GUI?" + +**对比表格** + +| 维度 | GUI (JavaFX) | CLI (命令行) | +|------|--------------|-------------| +| **代码量** | FXML + Controller + CSS,大量模板代码 | 纯Java,代码量可控 | +| **学习重心** | 布局、控件、事件监听 | 架构、分层、命令路由 | +| **后端能力** | 几乎无关 | 模拟真实服务器开发 | +| **可测试性** | 难(需要UI测试框架) | 易(直接测试Command类) | +| **工程思维** | 弱(关注视觉) | 强(关注逻辑) | + +**核心观点**: +> **CLI更需要MVC!** GUI有现成的事件系统(点击按钮→触发事件),而CLI只有字符流。**没有架构,分分钟写成脚本**。MVC在CLI里是"刚需",不是"装饰"。 +> +> **更深一层**:在GUI里,框架已经硬塞给你一套架构,你只是在填空;但在CLI里,所有结构都必须由你亲手搭建。**当外部约束消失,内部的工程纪律才真正开始建立**——这才是本节课的真正目的。 + +**CLI也能很酷**: +- ANSI彩色输出(红/绿/黄/蓝) +- 表格展示数据 +- 进度条动画 +- 模拟真实大数据开发场景 + +--- + +### 4.3 MVC分层设计(20分钟) + +#### 4.3.1 MVC的起源与演进 + +**教师口播**: +> "MVC不是新东西,它是1970年代为桌面应用设计的架构思想。但它的核心——'职责分离'——在任何软件里都适用。" + +| 年代 | 场景 | MVC的角色 | +|------|------|----------| +| 1970s | Smalltalk-72 GUI | 最早的用户界面架构 | +| 1990s | Web开发 (Struts) | 后端模板引擎 | +| 2000s | ASP.NET MVC | 现代Web框架 | +| 2020s | CLI + API | 解耦业务逻辑与表现层 | + +#### 4.3.2 从GUI到CLI的映射 + +| GUI组件 | CLI对应 | 说明 | +|--------|--------|------| +| 窗口/按钮 | 命令行输入 | **View = 用户交互** | +| 数据模型 | Article实体类 | **Model = 数据结构** | +| 事件监听 | Command路由 | **Controller = 调度** | + +#### 4.3.3 MVC三层职责 + +**架构图示**: + +``` +┌─────────────────────────────────────────┐ +│ 入口 │ +│ (main方法) │ +└─────────────────┬───────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Controller │ +│ - 接收命令(crawl, help, exit) │ +│ - 分发给对应的Command │ +│ 【口诀】:Controller不管"怎么做", │ +│ 只管"派给谁" │ +└─────────┬───────────────┬───────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Model │ │ View │ +│ - 数据实体 │ │ - 输入解析 │ +│ - 业务逻辑 │ │ - 输出格式化 │ +│ 【口诀】: │ │ 【口诀】: │ +│ Model管"数据" │ │ View管"呈现" │ +└─────────────────┘ └─────────────────┘ +``` + +**三层职责详解** + +| 层级 | 职责 | 典型代码 | 禁止做什么 | +|------|------|----------|------------| +| **Model** | 数据结构 + 业务逻辑 | `class Article { String title; String content; }` | 不能有`System.out.println`,不能有`Scanner` | +| **View** | 接收用户输入 + 格式化输出 | `class ConsoleView { String readInput(); void print(String); }` | 不能写爬虫逻辑,只做"传声筒" | +| **Controller** | 协调调度 | `class CrawlerController { void handle(String cmd) { ... } }` | 不能直接写业务细节,委托给Command | + +#### 4.3.4 类比强化:"餐厅类比" + +> "把MVC想象成一家餐厅: +> - **Model是后厨**:只管做菜(数据加工),不管谁来吃、怎么端 +> - **View是服务员**:只管端菜和收钱(输入输出),不管菜怎么做 +> - **Controller是前台**:只管把顾客的点单传给后厨,把做好的菜端给顾客 +> +> 如果后厨开始管'谁来吃饭',这餐厅就乱了。" + +#### 4.3.5 对"餐厅类比"的批判性思考(关键!) + +**教师导引**: +> "刚才的类比好理解吗?很好。但任何一个类比都有它的边界,如果把它当成真理,就会出问题。现在我们来给这个类比'找茬'。" + +**提问学生**: +1. "后厨真的完全不知道客人是谁吗?如果客人有忌口(比如不吃香菜),这个信息需不需要传到后厨?" +2. "服务员只是端菜吗?在真实餐厅里,服务员经常向后厨反馈'客人觉得今天的菜咸了',这属于View→Model的反向影响吗?" +3. "在这个类比里,我们把前台(Controller)和后厨(Model)的关系说成单向的。但实际上,后厨做完了菜,需要通知前台'菜好了',这不就是**观察者模式**吗?" + +**点明本质**: +> "实际MVC的数据流向常常是**双向**的:Controller调用Model的方法改变数据,Model变化后又通知View更新显示。只不过在本次CLI项目中,我们暂时使用'请求-响应'的单向简化模型——用户输入命令,系统处理,然后立即输出结果。这个简化版够用,但你要知道完整的MVC是更动态的。随着系统复杂,Model层需要一个专门的'仓库类'来管理数据,并通知视图刷新——这正是W10我们将要深入的内容。" + +#### 4.3.6 MVC的数据流向(本课程简化版) + +``` +CLI用户输入 + ↓ +View(解析命令字符串) + ↓ +Controller(找到对应Command) + ↓ +Command.execute()(执行业务逻辑) + ↓ +Model(Article数据,目前暂存于List) + ↓ +View(display()展示数据) + ↓ +CLI终端显示 +``` + +--- + +### 4.4 Command模式:可扩展的命令路由(15分钟) + +**教师口播**: +> "现在引入一个设计模式——Command(命令)模式。它的核心思想是:**一个命令就是一个类**。" + +#### 4.4.1 为什么需要Command模式? + +**演示:增加一个命令的代价(switch-case版)** +```java +// 现状代码 +switch (cmd) { + case "crawl": handleCrawl(); break; + case "help": showHelp(); break; + // 如果要增加 list 命令? + // 1. 加 case "list" + // 2. 加 handleList() 方法 + // 3. 可能还要改其他地方... +} +``` + +**提问**: +- "如果我想增加10个命令,这个类要改多少次?" +- "如果我不小心删了一个case,整个程序还能跑吗?" + +**痛点提炼**: +> "每加一个功能,就要在这个类里戳一个洞。**这就是'肥控制器'陷阱**——所有的逻辑都堆在Controller里,它变成了新的'意大利面'。" + +#### 4.4.2 Command模式的四个要素 + +| 要素 | 角色 | 示例 | +|------|------|------| +| **Command接口** | 抽象的"订单" | `Command` 接口 | +| **ConcreteCommand** | 具体的订单 | `HelpCommand`、`CrawlCommand` | +| **Invoker** | 接单的前台 | `CrawlerController` | +| **Receiver** | 执行者 | `ConsoleView`、`ArticleRepository` | + +#### 4.4.3 Command接口定义 + +```java +// src/main/java/com/crawler/command/Command.java +package com.crawler.command; + +import com.crawler.model.Article; +import java.util.List; + +public interface Command { + String getName(); // 命令名,如 "crawl" + void execute(String[] args, List
articles); // 执行逻辑 +} +``` + +#### 4.4.4 Controller的变革(从switch到Map) + +```java +// 修改后的Controller +public class CrawlerController { + private Map commands; // 用Map存命令 + private ConsoleView view; // 持有View以输出错误 + + public CrawlerController(ConsoleView view, List
articles) { + this.view = view; + this.commands = new HashMap<>(); + // 增加命令无需改Controller代码,只需在这里注册 + commands.put("crawl", new CrawlCommand(view)); + commands.put("help", new HelpCommand(view)); + commands.put("list", new ListCommand(view)); + commands.put("exit", new ExitCommand(view)); + } + + public void handle(String input) { + if (input.isEmpty()) return; + String[] parts = input.split("\\s+"); + String cmd = parts[0].toLowerCase(); + + Command command = commands.get(cmd); + if (command == null) { + view.printError("Unknown command: " + cmd); // 通过View输出,而非直接System.out + return; + } + + // 执行命令,传入参数和文章列表 + command.execute(parts, articles); + } +} +``` + +**对比表格** + +| 维度 | switch-case | Command模式 | +|------|-------------|-------------| +| 增加命令 | 要改Controller | 新建一个类 | +| 多态体验 | 无 | execute()的多态调用 | +| 可测试性 | 难 | 每个Command可单独测试 | +| 代码量 | 少 | 多,但更清晰 | + +**类比强化**: +> "Command模式就像**酒店的客房服务**:每个服务(清理、送餐、按摩)都是一个独立的部门。前台(Controller)只负责接电话,然后把请求'派发'给对应的部门。部门自己知道怎么干活,不需要前台教。" +> +> "如果想新增一个服务,前台只需要'登记'一下,不需要把现有部门重新装修。" + +--- + +### 4.5 Maven模板与环境(5分钟) + +**教师口播**: +> "这周我们不发愁pom.xml配置。我已经把 Maven 模板准备好了,你们只需要解压、打开、运行。" + +**模板使用流程**: +``` +1. 解压 [my-crawler-template.zip] +2. 用 IDEA 打开文件夹 +3. 右键 pom.xml → Maven → Reload Project +4. 运行 App.java +``` + +**标准目录结构**: +``` +src/main/java/com/crawler/ +├── model/ +│ └── Article.java +├── view/ +│ └── ConsoleView.java +├── command/ +│ ├── Command.java (接口) +│ ├── CrawlCommand.java +│ ├── HelpCommand.java +│ ├── ListCommand.java +│ └── ExitCommand.java +└── controller/ + └── CrawlerController.java +``` + +--- + +### 4.6 代码落地(20分钟) + +#### 4.6.1 Model层:Article实体 + +```java +// src/main/java/com/crawler/model/Article.java +package com.crawler.model; + +public class Article { + private String title; + private String url; + private String content; + + public Article(String title, String url, String content) { + this.title = title; + this.url = url; + this.content = content; + } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getUrl() { return url; } + public void setUrl(String url) { this.url = url; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + + @Override + public String toString() { + return "Article{title='" + title + "', url='" + url + "'}"; + } +} +``` + +#### 4.6.2 View层:ANSI常量集中管理(工程细节!) + +```java +// src/main/java/com/crawler/view/ConsoleView.java +package com.crawler.view; + +import com.crawler.model.Article; +import java.util.List; +import java.util.Scanner; + +public class ConsoleView { + // ANSI颜色常量——集中管理,避免散落各处 + private static final String ANSI_GREEN = "\033[32m"; + private static final String ANSI_RED = "\033[31m"; + private static final String ANSI_CYAN = "\033[36m"; + private static final String ANSI_RESET = "\033[0m"; + + private Scanner scanner = new Scanner(System.in); + + public String readLine() { + System.out.print("crawler> "); + return scanner.nextLine().trim(); + } + + public void print(String msg) { + System.out.println(msg); + } + + public void printSuccess(String msg) { + print(ANSI_GREEN + msg + ANSI_RESET); + } + + public void printError(String msg) { + print(ANSI_RED + msg + ANSI_RESET); + } + + public void printInfo(String msg) { + print(ANSI_CYAN + msg + ANSI_RESET); + } + + // 展示文章列表 + public void display(List
articles) { + if (articles.isEmpty()) { + printInfo("No articles yet. Use 'crawl ' first."); + return; + } + print("+----------+--------------------------------+"); + print("| Title | URL |"); + print("+----------+--------------------------------+"); + for (Article a : articles) { + String title = a.getTitle(); + if (title.length() > 10) title = title.substring(0, 10) + ".."; + String url = a.getUrl(); + if (url.length() > 30) url = url.substring(0, 27) + "..."; + print("| " + String.format("%-10s", title) + " | " + url + " |"); + } + print("+----------+--------------------------------+"); + printInfo("Total: " + articles.size() + " articles"); + } +} +``` + +**教师提示**: +> "注意:所有ANSI转义码都被定义为`private static final`常量。如果把`\033[32m`散落在项目各处,一旦想调整颜色,就得满世界去改——这正是我们之前痛批的'意大利面'。**这就是工程细节**。" + +#### 4.6.3 Command接口与四个实现(全部通过View输出) + +```java +// Command.java +public interface Command { + String getName(); + void execute(String[] args, List
articles); +} + +// HelpCommand.java +public class HelpCommand implements Command { + private ConsoleView view; + public HelpCommand(ConsoleView v) { this.view = v; } + public String getName() { return "help"; } + public void execute(String[] args, List
articles) { + view.printInfo("Commands: crawl , list, help, exit"); + } +} + +// ListCommand.java +public class ListCommand implements Command { + private ConsoleView view; + public ListCommand(ConsoleView v) { this.view = v; } + public String getName() { return "list"; } + public void execute(String[] args, List
articles) { + view.display(articles); + } +} + +// CrawlCommand.java (存根) +public class CrawlCommand implements Command { + private ConsoleView view; + public CrawlCommand(ConsoleView v) { this.view = v; } + public String getName() { return "crawl"; } + public void execute(String[] args, List
articles) { + if (args.length < 2) { + view.printError("Usage: crawl "); + return; + } + view.printInfo("Stub: Would crawl " + args[1]); + } +} + +// ExitCommand.java +public class ExitCommand implements Command { + private ConsoleView view; + public ExitCommand(ConsoleView v) { this.view = v; } + public String getName() { return "exit"; } + public void execute(String[] args, List
articles) { + view.printSuccess("Bye!"); // 全部输出都通过View,绝不让System.out直接出现在这里 + System.exit(0); + } +} +``` + +**故意埋设的"找茬点"**: +> "我在刚才的代码里有没有隐藏违反MVC原则的地方?`CrawlCommand`的存根里,`view.printInfo("Stub: Would crawl " + args[1]);` —— 这个字符串拼接算是"业务逻辑"吗?留给大家用AI架构审计时讨论。 + +#### 4.6.4 Controller:Map路由(全部通过View输出) + +```java +// src/main/java/com/crawler/controller/CrawlerController.java +package com.crawler.controller; + +import com.crawler.command.*; +import com.crawler.model.Article; +import com.crawler.view.ConsoleView; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class CrawlerController { + private Map commands = new HashMap<>(); + private ConsoleView view; // 持有View + private List
articles; + + public CrawlerController(ConsoleView view, List
articles) { + this.view = view; + this.articles = articles; + commands.put("help", new HelpCommand(view)); + commands.put("list", new ListCommand(view)); + commands.put("crawl", new CrawlCommand(view)); + commands.put("exit", new ExitCommand(view)); + } + + public void handle(String input) { + if (input.isEmpty()) return; + String[] parts = input.split("\\s+"); + String cmdName = parts[0].toLowerCase(); + + Command cmd = commands.get(cmdName); + if (cmd == null) { + view.printError("Unknown command: " + cmdName); // 错误信息也走View! + return; + } + cmd.execute(parts, articles); + } +} +``` + +#### 4.6.5 main方法:组装 + +```java +// src/main/java/com/crawler/App.java +package com.crawler; + +import com.crawler.controller.CrawlerController; +import com.crawler.model.Article; +import com.crawler.view.ConsoleView; +import java.util.ArrayList; +import java.util.List; + +public class App { + public static void main(String[] args) { + ConsoleView view = new ConsoleView(); + List
articles = new ArrayList<>(); + CrawlerController controller = new CrawlerController(view, articles); + + view.printSuccess("Welcome to CLI Crawler!"); + view.printInfo("Type 'help' for commands."); + + while (true) { + controller.handle(view.readLine()); + } + } +} +``` + +#### 4.6.6 架构反思与展望:共享List
的隐患(关键!) + +**教师口播**: +> "现在这个架构已经可用了。但请大家审视一下:我们所有的Command都直接拿到了`List
`的引用。换句话说,任何一个命令都可以随意增、删、改这个列表。" +> +> "这就好像一家酒店,所有服务员、厨师、清洁工都能随意进出保险箱——**数据结构完全裸奔了**。" + +**提问**: +- "如果CrawlCommand不小心写错了代码,把一个null塞进articles,HelpCommand会不会受影响?" +- "如果未来我们要在添加文章时也写入日志文件,现在的设计能优雅实现吗?还是得在所有Command里分别加日志代码?" + +**预告解决方案**: +> "下周,我们将引入**策略模式**和一个真正的**Model仓库层(ArticleRepository)**。这个仓库会把`List`封装起来,对外只提供`add()`、`getAll()`等安全接口。任何命令想修改数据,都必须通过仓库。这就是从'数据结构'到'模型层'的进化——我们W9先搭骨架,W10给它装上盔甲。" + +--- + +### 4.7 实践任务(5分钟) + +**任务要求**: +1. 使用Maven模板创建项目 +2. 实现完整包结构(model/view/command/controller) +3. 实现4个Command:help/list/crawl/exit +4. `list`命令能展示已抓取的文章 +5. 运行并测试循环 +6. **代码找茬(额外加分)**:找出你自己代码中是否存在`System.out`直接调用、硬编码ANSI字符串等"越权行为" + +**验收标准**: +- [x] Maven编译通过 +- [x] Command接口和4个实现分离在不同文件 +- [x] Controller里没有switch-case +- [x] 新增命令只需新建类,不改Controller +- [x] list命令能正确显示空列表 +- [x] 所有输出均通过ConsoleView完成,无直接System.out.println(main除外) +- [x] ANSI颜色码集中定义为View常量 + +--- + +## 五、课后作业 + +### 5.1 必做任务 + +1. **完善Article**:增加`author`、`publishDate`字段 +2. **★ HistoryCommand(强制作业)**: + - 实现`history`命令,记录用户输入过的所有命令 + - 使用`List`存储历史(复习W8集合) + - 示例输出: + ``` + crawler> history + 1. help + 2. list + 3. crawl https://example.com + ``` +3. **AI架构审计**:将类名和方法名发给AI,指令: + > "作为Java架构审计师,请检查我的MVC三层划分是否存在越权行为?Model层是否包含输入输出代码?View层是否越权写了业务逻辑?有没有地方直接使用了System.out或硬编码ANSI码?" + +### 5.2 选做任务 + +1. **命令别名**:给`crawl`增加别名`c`,`help`增加别名`h` +2. **URL验证**:检查URL格式是否以http://或https://开头 +3. **暗色主题**:实现不同的配色方案(利用View中的ANSI常量,只需修改一处即可) +4. **思考并回答**:分析`List
`共享引用的潜在风险,写一段200字的小结 + +### 5.3 思考题 + +1. **Command vs switch-case**:增加10个命令,哪种方式代码改动量更小? +2. **如果不用Command接口,直接用Map存命令类行不行?** 接口的意义是什么? +3. **Controller里的`commands.put()`能否减少?** 提示:思考"注册机制" +4. **为什么ExitCommand里的`view.printSuccess("Bye!")`比直接`System.out.println`更"MVC"?** 提示:回忆View的职责 + +--- + +## 六、AI协同升级 + +### 架构审计师任务(必做) + +**学生执行步骤**: +1. 列出项目中所有类名(不含方法实现) +2. 将类名列表发给AI +3. 输入指令: + > "作为Java架构审计师,请检查我的MVC三层划分是否清晰。Model层是否包含了不应该有的代码(Scanner/System.out)?View层是否越权写了业务逻辑?请指出任何一处直接使用System.out.println的地方,并建议如何改正。" + +**预期AI输出**: +- 指出哪一层有越权行为 +- 建议如何整改 +- 评价整体架构健康度 + +### 进阶AI探究(选做) + +> "假设我的Command接口中execute方法接收了一个`List
`参数,请分析这种设计在工程上有什么隐患,并给出重构建议。" + +--- + +## 七、教学反思与调整记录 + +| 日期 | 事项 | 调整内容 | +|------|------|----------| +| 2026-04-28 | 首次编写 | 基于CLI+MVC重构 | +| 2026-04-30 | 教授反馈 | 引入Command模式、提供Maven模板、升级AI协同比 | +| 2026-04-30 | 逻辑重排 | 按"问题→选择→架构→模式"顺序重写 | +| 2026-05-01 | v2 vs V3合并 | 融合深度改进:增加教育哲学、批判性思考、ANSI常量、共享List隐患、故意埋坑 | + +--- + +## 附录1:Maven模板说明 + +> 老师提供`my-crawler-template.zip`压缩包,包含: +> - pom.xml(含Jsoup依赖) +> - 空的src/main/java结构 +> - .gitignore + +## 附录2:常见问题速查 + +| 问题 | 解答 | +|------|------| +| IDEA不识别pom.xml | 右键 pom.xml → Maven → Reload Project | +| 中文乱码 | Settings → Editor → File Encodings → UTF-8 | +| 包名大小写 | 包名全小写,类名首字母大写 | +| Command找不到 | 检查是否 implements Command,是否 @Override getName() | +| 命令不生效 | 检查 commands.put() 是否注册了该命令 | +| 输出颜色乱码 | IDEA控制台需支持ANSI,Windows下建议使用Windows Terminal或调整设置 | +| 我的System.out为什么被老师说越权 | View层才是与用户交互的唯一出口,所有输出都应通过View,这样将来改成GUI或日志时只需改View | + +## 附录3:教学逻辑说明 + +| 顺序 | 内容 | 设计理由 | +|------|------|----------| +| 1 | 痛点引入 | 从问题出发,让学生感受"为什么需要架构" | +| 2 | CLI vs GUI | 解释技术选型,建立"工程思维 > 视觉装饰"的认知 | +| 3 | MVC分层 | 核心架构概念,理解职责分离,通过类比及批判加深理解 | +| 4 | Command模式 | 具体实现方式,解决"肥控制器"问题 | +| 5 | Maven | 工具链支持 | +| 6 | 代码落地 | 实践验证,刻意植入细节规范,训练工程洁癖 | +| 7 | 架构反思 | 暴露共享可变状态隐患,为W10策略模式+仓库层做铺垫 | +| 8 | 实践任务 | 现场编码验证 | +| 9 | 总结 | 强化认知,预告下周 | + +--- + +## 版本说明 + +- **v1**:首次编写,CLI+MVC基础框架 +- **v2**:按"问题→选择→架构→模式"逻辑重排 +- **v3 (本版)**:融合v2结构 + V3深度改进,包含: + - 更深的CLI教育哲学 + - 餐厅类比批判性思考 + - ANSI常量集中管理工程细节 + - 全部输出走View + - 共享List架构隐患反思 + - 故意埋坑让学生找茬 + - W10铺垫(策略模式+仓库层) \ No newline at end of file diff --git a/w9/java-cli/target/maven-archiver/pom.properties b/w9/java-cli/target/maven-archiver/pom.properties new file mode 100644 index 0000000..08a8f9f --- /dev/null +++ b/w9/java-cli/target/maven-archiver/pom.properties @@ -0,0 +1,5 @@ +#Generated by Maven +#Thu Apr 30 11:50:54 CST 2026 +artifactId=datacollect-cli +groupId=com.example +version=0.1.0 diff --git a/w9/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/w9/java-cli/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..e69de29 diff --git a/w9/java-cli/target/w9-ppt.md b/w9/java-cli/target/w9-ppt.md new file mode 100644 index 0000000..5ddd5ad --- /dev/null +++ b/w9/java-cli/target/w9-ppt.md @@ -0,0 +1,530 @@ +## 高级程序设计 · 第9周 + +#### 工程架构:从"写代码"到"造系统" + +##### CLI + MVC + Command模式实战 + +--- + +### 📌 本周导航 + +- 痛点引入:脚本的宿命 +- CLI vs GUI:为什么选命令行? +- MVC分层:职责分离的艺术 +- Command模式:可扩展的路由 +- Maven模板:工程化第一步 +- 代码落地:从接口到实现 +- 架构反思:共享数据的隐患 +- 实践任务 + 课后作业 + +--- + +### 1️⃣ 痛点引入:从脚本到工程的鸿沟 + +#### 这是一段“意大利面”爬虫 + +```java +public class Crawler { + public static void main(String[] args) { + System.out.print("请输入URL: "); + Scanner scanner = new Scanner(System.in); + String url = scanner.nextLine(); + List titles = new ArrayList(); + try { + Document doc = Jsoup.connect(url).get(); + Elements elements = doc.select(".post-title"); + for (Element e : elements) { + String title = e.text(); + System.out.println("标题: " + title); + titles.add(title); + } + } catch (Exception ex) { + System.out.println("出错啦: " + ex.getMessage()); + } + } +} +``` + +--- + +### 脚本的三大痛点 + +| 需求 | 需要改哪里? | +|------|--------------| +| 保存标题到文件 | 改 main 内部逻辑 | +| 支持不同网站结构 | 全部重写解析代码 | +| 彩色输出 | 一个一个改 print | + +> 😫 **牵一发而动全身 → 改起来疼** + +### 本周目标:**让代码“改起来不疼”** + +--- + +## 2️⃣ CLI vs GUI:架构选择的思考 + +### 图形界面 vs 命令行 + +| 维度 | GUI (JavaFX) | CLI (命令行) | +|------|--------------|-------------| +| 学习重心 | 布局、控件、事件 | **架构、分层、路由** | +| 后端能力 | 弱 | 模拟真实服务器 | +| 工程思维 | 弱(关注视觉) | **强(关注逻辑)** | +| 可测试性 | 难 | 易 | + +--- + +## 核心观点 + +> **CLI 更需要 MVC!** + +- GUI 有现成事件系统,框架强塞给你一套架构 +- CLI 只有字符流 → **没有架构,分分钟写成脚本** + +> 🎯 **当外部约束消失,内部的工程纪律才真正开始建立** + +### CLI 也能很酷 + +- ANSI 彩色输出 +- 表格展示数据 +- 模拟大数据/后端开发 + +--- + +## 3️⃣ MVC 分层设计 + +### MVC 的起源与演进 + +| 年代 | 场景 | MVC的角色 | +|------|------|----------| +| 1970s | Smalltalk-72 GUI | 最早的用户界面架构 | +| 1990s | Web开发 (Struts) | 后端模板引擎 | +| 2000s | ASP.NET MVC | 现代Web框架 | +| 2020s | CLI + API | 解耦业务逻辑与表现层 | + +**核心不变:职责分离** + +--- + +## MVC 三层职责 + +![[mvc.png]] +``` +┌─────────────────────────────────────────┐ +│ 入口 │ +│ (main方法) │ +└─────────────────┬───────────────────────┘ + ▼ +┌─────────────────────────────────────────┐ +│ Controller │ +│ 只管"派给谁",不管"怎么做" │ +└─────────┬───────────────┬───────────────┘ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Model │ │ View │ +│ 管"数据" │ │ 管"呈现" │ +│ + 业务逻辑 │ │ + 输入输出 │ +└─────────────────┘ └─────────────────┘ +``` + +--- + +## 三层“禁止做什么” + +| 层级 | 禁止行为 | +| -------------- | -------------------------------------- | +| **Model** | 不能有 `System.out.println`,不能有 `Scanner` | +| **View** | 不能写爬虫逻辑,只做“传声筒” | +| **Controller** | 不能直接写业务细节,委托给 Command | + +> 🔴 **越权就是架构腐败的开始** + +--- + +## 🍽️ 餐厅类比(帮助理解) + +- **Model = 后厨**:只管做菜,不管谁来吃、怎么端 +- **View = 服务员**:只管端菜和收钱,不管菜怎么做 +- **Controller = 前台**:接单 → 派给后厨 → 叫服务员上菜 + +--- + +## 🤔 对类比的批判性思考(关键!) + +> 任何类比都有边界,不要当成真理 + +| 场景 | 暴露的问题 | +|------|------------| +| 客人有忌口(不吃香菜) | 信息需要传到后厨 → Model 可能需要知道 meta 信息 | +| 服务员反馈“今天的菜咸了” | View → Model 反向影响 | +| 后厨做完菜通知前台 | **观察者模式**,数据流可能是双向的 | + +**本课程简化模型**:请求-响应,单向流 + +--- + +## MVC 数据流向(本课程简化版) + +``` +CLI用户输入 + ↓ +View(解析命令字符串) + ↓ +Controller(找到对应Command) + ↓ +Command.execute()(执行业务逻辑) + ↓ +Model(Article数据,暂存于List) + ↓ +View(display()展示数据) + ↓ +CLI终端显示 +``` + +--- + +## 4️⃣ Command 模式:可扩展的命令路由 + +### 为什么需要 Command 模式? + +```java +switch (cmd) { + case "crawl": handleCrawl(); break; + case "help": showHelp(); break; + // 如果要增加 list 命令? + // 1. 加 case "list" + // 2. 加 handleList() 方法 + // 3. 可能还要改其他地方... +} +``` + +> 每加一个功能,就要在这个类里戳一个洞 → **肥控制器陷阱** + +--- + +## Command 模式的四个要素 + +| 要素 | 角色 | 示例 | +|------|------|------| +| Command接口 | 抽象的“订单” | `Command` | +| ConcreteCommand | 具体的订单 | `HelpCommand` | +| Invoker | 接单的前台 | `CrawlerController` | +| Receiver | 执行者 | `ConsoleView`、`ArticleRepository` | + +--- + +## Command 接口定义 + +```java +package com.crawler.command; + +import com.crawler.model.Article; +import java.util.List; + +public interface Command { + String getName(); + void execute(String[] args, List
articles); +} +``` + +--- + +## Controller 的变革:从 switch 到 Map + +```java +public class CrawlerController { + private Map commands = new HashMap<>(); + + public CrawlerController(ConsoleView view, List
articles) { + commands.put("help", new HelpCommand(view)); + commands.put("list", new ListCommand(view)); + commands.put("crawl", new CrawlCommand(view)); + commands.put("exit", new ExitCommand(view)); + } + + public void handle(String input) { + // 解析命令 → 从 Map 取 Command → 调用 execute + } +} +``` + +> **增加新命令:只需新建类,Controller 零改动!** + +--- + +## 对比:switch-case vs Command + +| 维度 | switch-case | Command模式 | +|------|-------------|-------------| +| 增加命令 | 要改 Controller | 新建一个类 | +| 多态体验 | 无 | `execute()` 多态 | +| 可测试性 | 难 | 每个 Command 单独测试 | +| 代码量 | 少 | 多,但更清晰 | + +> 🏨 **类比:酒店客房服务,前台只负责派单** + +--- + +## 5️⃣ Maven 模板与环境(5分钟) + +### 直接使用模板,不折腾配置 + +``` +my-crawler-template.zip + ↓ 解压 + IDEA打开 + ↓ 右键 pom.xml → Maven → Reload Project + ↓ 运行 App.java +``` + +### 标准目录结构 + +``` +src/main/java/com/crawler/ +├── model/Article.java +├── view/ConsoleView.java +├── command/ +│ ├── Command.java +│ ├── CrawlCommand.java +│ ├── HelpCommand.java +│ ├── ListCommand.java +│ └── ExitCommand.java +└── controller/CrawlerController.java +``` + +--- + +## 6️⃣ 代码落地(分步实现) + +### Model:Article 实体 + +```java +public class Article { + private String title; + private String url; + private String content; + // 构造器、getter/setter、toString +} +``` + +> 📦 只存放数据,没有任何输入输出代码 + +--- + +## View:ConsoleView(ANSI常量集中管理) + +```java +public class ConsoleView { + private static final String ANSI_GREEN = "\033[32m"; + private static final String ANSI_RED = "\033[31m"; + // ... 其他常量 + + public void printSuccess(String msg) { + System.out.println(ANSI_GREEN + msg + ANSI_RESET); + } + public void printError(String msg) { ... } + public void display(List
articles) { ... } +} +``` + +> ✨ **所有颜色码集中定义 → 改主题只需改一处** + +--- + +## Command 实现示例(HelpCommand) + +```java +public class HelpCommand implements Command { + private ConsoleView view; + public HelpCommand(ConsoleView v) { this.view = v; } + public String getName() { return "help"; } + public void execute(String[] args, List
articles) { + view.printInfo("Commands: crawl , list, help, exit"); + } +} +``` + +> ⚠️ 全部输出通过 `view`,绝不让 `System.out` 直接出现在这里 + +--- + +## CrawlCommand(存根,下周填坑) + +```java +public class CrawlCommand implements Command { + private ConsoleView view; + public CrawlCommand(ConsoleView v) { this.view = v; } + public String getName() { return "crawl"; } + public void execute(String[] args, List
articles) { + if (args.length < 2) { + view.printError("Usage: crawl "); + return; + } + view.printInfo("Stub: Would crawl " + args[1]); + } +} +``` + +> 🔍 **找茬点**:这里拼接字符串算是“业务逻辑”吗?留给大家用 AI 审计。 + +--- + +## ExitCommand + +```java +public class ExitCommand implements Command { + private ConsoleView view; + public ExitCommand(ConsoleView v) { this.view = v; } + public String getName() { return "exit"; } + public void execute(String[] args, List
articles) { + view.printSuccess("Bye!"); + System.exit(0); + } +} +``` + +> ✅ 所有输出都通过 View → 将来改 GUI 只需换 View 实现 + +--- + +## Controller + main 组装 + +```java +// Controller 中持有 Map +// App.java 中: +ConsoleView view = new ConsoleView(); +List
articles = new ArrayList<>(); +CrawlerController controller = new CrawlerController(view, articles); +view.printSuccess("Welcome to CLI Crawler!"); +while (true) { + controller.handle(view.readLine()); +} +``` + +> 🔁 完成交互循环 + +--- + +## 7️⃣ 架构反思:共享 List
的隐患 + +### 当前问题 + +- 所有 Command 都直接拿到 `List
` 引用 +- 任何一个命令都可以随意增、删、改列表 +- 数据完全“裸奔” + +> 🚨 就像酒店所有员工都能进保险箱 + +--- + +## 提问 + +- 如果 `CrawlCommand` 不小心把 `null` 塞进列表,`ListCommand` 会怎样? +- 如果我们要在添加文章时写日志,现在的设计能优雅实现吗? + +### 预告解决方案(W10) + +- **策略模式** + **仓库层(ArticleRepository)** +- 封装 `List`,对外只暴露 `add()`、`getAll()` 等安全接口 + +> W9 搭骨架,W10 装上盔甲 + +--- + +## 8️⃣ 实践任务(现场5分钟) + +### 必做项 + +1. 使用 Maven 模板创建项目 +2. 实现完整包结构(model/view/command/controller) +3. 实现 4 个 Command:help / list / crawl / exit +4. `list` 能展示已抓取的文章(目前存根即可) +5. 运行并测试循环 + +### 额外加分:代码找茬 + +- 检查是否仍有 `System.out` 直接调用 +- 检查 ANSI 码是否硬编码在多个地方 + +--- + +## 验收标准 + +- [x] Maven 编译通过 +- [x] Command 接口和 4 个实现在不同文件 +- [x] Controller 里没有 switch-case +- [x] 新增命令只需新建类,不改 Controller +- [x] list 能正确显示空列表 +- [x] 所有输出均通过 `ConsoleView` +- [x] ANSI 颜色码集中定义为常量 + +--- + +## 9️⃣ 课后作业 + +### 必做 + +1. **完善 Article**:增加 `author`、`publishDate` 字段 +2. **★ HistoryCommand**:记录用户输入过的所有命令(用 `List`) +3. **AI 架构审计**:将类名发给 AI,指令: + > “作为Java架构审计师,请检查我的MVC三层划分是否存在越权行为?” + +### 选做 + +- 命令别名(c 代替 crawl) +- URL 格式验证 +- 暗色主题(修改一处常量) +- 思考题:分析 `List
` 共享引用的风险(200字小结) + +--- + +## 🤖 AI 协同升级 + +### 架构审计师任务(必做) + +**步骤**: +1. 列出所有类名(不含方法实现) +2. 发给 AI +3. 指令:“检查 MVC 分层是否清晰,是否有越权行为” + +### 进阶探究(选做) + +> “假设我的 Command 接口中 execute 方法接收了一个 `List
` 参数,请分析这种设计在工程上有什么隐患,并给出重构建议。” + +--- + +## 📚 总结与过渡 + +### 本周成果 + +- ✅ 工程化包结构 +- ✅ MVC 分层清晰 +- ✅ Command 模式实现可扩展路由 +- ✅ 所有输出走 View,常量集中管理 + +### 下周预告 + +- **策略模式**:封装爬取算法 +- **仓库层(Repository)**:武装 `List
`,解决共享隐患 + +> 🚀 从“写代码”到“造系统”,踏出坚实第一步! + +--- + +## Q&A + +### 常见问题 + +| 问题 | 解答 | +|------|------| +| IDEA 不识别 pom.xml | 右键 → Maven → Reload Project | +| 中文乱码 | Settings → File Encodings → UTF-8 | +| 输出颜色乱码 | Windows 建议使用 Windows Terminal | +| 我的 System.out 被批评 | View 才是唯一输出出口 | + +--- + +## 谢谢! + +### 课件已上传,模板在课程群 + +**保持工程洁癖,下周见!** \ No newline at end of file diff --git a/w9/思考题.docx b/w9/思考题.docx new file mode 100644 index 0000000000000000000000000000000000000000..609f47b8300fc3ae9b4572e79e72b02330c3de0d GIT binary patch literal 10703 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