从数据到洞察:一个完整的大数据课程设计实战指南

最近在辅导几位本科生的课程设计,发现很多同学对“大数据项目”的理解还停留在理论层面,一提到Hadoop、MapReduce就觉得是庞然大物,不知从何下手。其实,一个完整的数据分析项目,从数据获取到最终可视化,每个环节都有成熟的工具和清晰的路径。今天我就以大家熟悉的旅游网站数据为例,拆解一个可落地、可复现的课程设计全流程。这个项目不仅适合计算机、软件工程专业的学生,对数据科学、信息管理相关专业的同学也有参考价值。

我们会从最实际的问题出发:如何获取数据?数据拿到手怎么清洗?海量数据存哪里?怎么分析?分析结果又如何展示?我会把每个环节的核心代码、避坑经验、配置细节都分享出来,你可以直接跟着做,也可以根据自己手头的硬件资源进行调整。整个项目的技术栈选择了目前高校和企业中依然广泛使用的经典组合:Java + Jsoup + Hadoop/HBase + ECharts,确保环境的普适性和学习资源的丰富性。

1. 项目蓝图:不只是写代码,更是理解数据生命周期

在动手敲第一行代码之前,我们得先想清楚这个项目到底要做什么。很多课程设计失败,不是因为技术太难,而是目标太模糊。我们的核心目标是:利用公开的旅游网站数据,完成一次从数据采集到分析洞察的完整实践,并产出有价值的可视化报告。

1.1 明确分析维度与业务价值

一个数据分析项目必须有明确的业务导向。对于酒店数据,我们可以从多个维度切入:

  • 价格分析:不同城市的酒店平均价格分布如何?哪些城市属于高端消费市场,哪些是性价比之选?
  • 服务评价分析:用户评论中高频出现的词汇是什么?是“干净”、“舒适”这类正面词汇多,还是“吵闹”、“陈旧”这类负面词汇多?
  • 地理位置分析:酒店的地理分布是否有规律?是否集中在景区或交通枢纽附近?
  • 品牌/星级分析:不同星级的酒店在价格、评分上差异有多大?

对于课程设计,我建议聚焦在前两点:城市酒店平均价格统计用户评论词频分析。这两个目标足够具体,能覆盖数据处理的全流程,且结果直观,易于展示。

注意:在进行任何网络数据采集前,务必仔细阅读目标网站的robots.txt协议和使用条款。本教程旨在教学演示,所有操作应遵守相关法律法规和网站规定,控制请求频率,避免对目标服务器造成压力。建议使用公开的数据集或已获得授权的数据进行学习和研究。

1.2 技术栈选型与工具准备

工欲善其事,必先利其器。下面是完成本项目需要准备的核心软件环境。我强烈建议使用Docker来搭建Hadoop生态集群,这能省去大量繁琐的环境配置和依赖冲突问题。

组件版本作用备注
JavaJDK 8项目主要开发语言Hadoop 2.x 对 JDK 8 兼容性最好
IDEIntelliJ IDEA集成开发环境Eclipse 亦可,但 IDEA 的提示更友好
构建工具Maven依赖管理简化 Jar 包管理,特别是Hadoop相关依赖
数据采集Jsoup 1.15.x解析HTML,抓取网页数据轻量级,API简单易用
分布式存储Hadoop 2.7.7HDFS分布式文件系统课程设计常用稳定版本
分布式数据库HBase 1.4.13存储非结构化的酒店和评论数据与Hadoop 2.7.x版本匹配
协调服务ZooKeeper 3.4.14为HBase提供元数据管理与故障恢复
数据分析Hadoop MapReduce分布式计算框架核心分析引擎
数据可视化ECharts 5.x前端图表库功能强大,文档丰富,易于集成
容器化 (推荐)Docker & Docker Compose一键部署Hadoop/HBase集群极大降低环境搭建难度

如果你的电脑内存有限(比如只有8GB),在本地用Docker跑一个伪分布式集群是完全可行的。下面是一个简化的docker-compose.yml示例,用于快速启动一个包含HDFS、YARN和HBase的单节点环境:

version: '3'
services:
  namenode:
    image: bde2020/hadoop-namenode:2.0.0-hadoop2.7.4-java8
    container_name: namenode
    ports:
      - "9870:9870"  # HDFS Web UI
    environment:
      - CLUSTER_NAME=test
    volumes:
      - ./data/namenode:/hadoop/dfs/name

  datanode:
    image: bde2020/hadoop-datanode:2.0.0-hadoop2.7.4-java8
    container_name: datanode
    depends_on:
      - namenode
    environment:
      - CORE_CONF_fs_defaultFS=hdfs://namenode:8020
    volumes:
      - ./data/datanode:/hadoop/dfs/data

  hbase:
    image: harisekhon/hbase:1.4
    container_name: hbase
    depends_on:
      - namenode
      - datanode
    ports:
      - "16010:16010" # HBase Web UI
    environment:
      - HBASE_HDFS_ROOT_DIR=hdfs://namenode:8020/hbase

使用docker-compose up -d命令即可启动。之后,你就可以在本地通过Java客户端连接这个集群了。

2. 实战第一步:高效且合规的数据采集策略

数据是分析的基石。直接从网页抓取数据,你会遇到各种真实世界的问题:页面结构复杂、数据通过AJAX加载、有反爬机制等。这里我们分步解决。

2.1 使用Jsoup解析静态页面结构

假设我们要抓取城市列表页面,每个城市条目包含一个链接,指向该城市的酒店列表。Jsoup的核心是使用CSS选择器或类似DOM的方法来定位元素。

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.io.IOException;

public class CityListCrawler {
    public static void main(String[] args) throws IOException {
        // 1. 目标URL(此处为示例,请替换为实际地址)
        String url = "https://hotels.ctrip.com/citylist";

        // 2. 发送HTTP GET请求,获取文档对象
        // 设置超时和User-Agent是基本礼仪,模拟浏览器访问
        Document doc = Jsoup.connect(url)
                .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
                .timeout(10000)
                .get();

        // 3. 分析页面,找到城市列表所在的容器
        // 假设城市链接都在 class 为 'city-item' 的 <a> 标签里
        Elements cityLinks = doc.select("a.city-item");

        // 4. 遍历并提取信息
        for (Element link : cityLinks) {
            String cityName = link.text(); // 城市名
            String cityHref = link.attr("href"); // 城市链接
            // 通常链接中会包含城市ID,可以用正则表达式提取
            String cityId = extractCityIdFromHref(cityHref);

            System.out.println(String.format("城市: %s, ID: %s, 链接: %s",
                    cityName, cityId, cityHref));
            // 这里可以将 cityId 存入列表,供后续抓取酒店详情使用
        }
    }

    private static String extractCityIdFromHref(String href) {
        // 简单示例:假设链接格式为 `/hotels/city123`
        // 使用正则提取数字部分
        java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("city(\\d+)");
        java.util.regex.Matcher matcher = pattern.matcher(href);
        if (matcher.find()) {
            return matcher.group(1);
        }
        return "未知";
    }
}

运行这段代码,你就能获得一个城市ID的列表。这是后续抓取酒店数据的关键参数

2.2 应对动态加载与JSON数据接口

现代网站大量使用AJAX,酒店列表和价格信息往往不是直接写在HTML里,而是通过接口返回JSON。你需要打开浏览器的开发者工具(F12),切换到 Network(网络) 标签页,在页面中滚动或点击时,观察有哪些XHR(异步)请求被发出。

你会发现一个形如 https://hotels.ctrip.com/api/.../hotelList 的POST请求,其请求参数中必然包含我们刚才抓取到的 cityId。这时,用Jsoup模拟POST请求就不太方便了,我们可以换用更通用的 HttpClient 或者 OkHttp 库。

import okhttp3.*;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.IOException;

public class HotelDataFetcher {
    private static final OkHttpClient client = new OkHttpClient();

    public static JSONArray fetchHotelsByCity(String cityId) throws IOException {
        // 1. 构建请求体(表单格式或JSON格式,需根据实际接口调整)
        RequestBody body = new FormBody.Builder()
                .add("cityId", cityId)
                .add("pageIndex", "1")
                .add("pageSize", "50") // 控制单次请求数量,避免被封
                // ... 其他必要参数
                .build();

        // 2. 构建请求
        Request request = new Request.Builder()
                .url("https://hotels.ctrip.com/api/xxx/hotelList")
                .post(body)
                .addHeader("User-Agent", "Mozilla/5.0...")
                .addHeader("Referer", "https://hotels.ctrip.com/") // 有时需要Referer
                .build();

        // 3. 发送请求并获取响应
        try (Response response = client.newCall(request).execute()) {
            if (!response.isSuccessful()) {
                throw new IOException("Unexpected code " + response);
            }
            String responseData = response.body().string();
            // 4. 解析JSON响应
            JSONObject json = new JSONObject(responseData);
            // 根据实际JSON结构,找到酒店列表数组
            return json.getJSONObject("data").getJSONArray("hotelList");
        }
    }

    // 解析单个酒店JSON对象,转换为我们的Hotel实体类
    public static Hotel parseHotel(JSONObject hotelJson) {
        Hotel hotel = new Hotel();
        hotel.setId(hotelJson.getString("hotelId"));
        hotel.setName(hotelJson.getString("hotelName"));
        hotel.setPrice(hotelJson.optDouble("price", 0.0)); // optDouble可处理空值
        hotel.setScore(hotelJson.optDouble("score", 0.0));
        hotel.setStar(hotelJson.optString("star", ""));
        // ... 解析其他字段
        return hotel;
    }
}

关键技巧

  • 控制节奏:在循环抓取不同城市或分页时,务必使用 Thread.sleep(randomTime) 添加随机延迟(如1-3秒),这是最基本的反反爬策略。
  • 异常处理:网络请求不稳定,一定要用 try-catch 包裹,并记录失败的任务,便于后续重试。
  • 数据落地:不要把所有数据都放在内存里。每抓取完一批(比如一个城市的50家酒店),就立即写入本地文件(如JSON行格式)或直接写入HBase。

3. 构建数据仓库:HBase表设计与高效写入

原始数据抓取下来后,我们需要一个地方来存储它。关系型数据库(如MySQL)在处理海量、半结构化的酒店和评论数据时容易遇到瓶颈。HBase作为分布式列式数据库,非常适合这种场景。

3.1 设计合理的HBase表结构

HBase的表设计核心思想是:RowKey决定数据分布,列族划分访问模式

表一:城市酒店信息表 (t_city_hotel)

  • RowKey设计城市ID_酒店ID (例如 10_12345)。这样,同一城市的酒店在物理存储上会尽量靠近,有利于基于城市的查询。
  • 列族 cf_info:存放酒店基本不变的信息。
    • name: 酒店名称
    • star: 星级
    • address: 地址
    • lon, lat: 经纬度
  • 列族 cf_stat:存放可能变动的统计信息。
    • price: 当前价格
    • score: 评分
    • dp_count: 点评数量
    • update_time: 更新时间

表二:酒店评论表 (t_hotel_comment)

  • RowKey设计酒店ID_时间戳反转_评论ID (例如 12345_9223372036854775807_abcde)。时间戳反转(Long.MAX_VALUE - timestamp)能让最新的评论排在前面,方便按时间倒序扫描。
  • 列族 cf_content
    • content: 评论正文
    • user_nick: 用户昵称
    • check_in_date: 入住日期

这样的设计,使得后续的MapReduce作业扫描起来非常高效。

3.2 编写通用的HBase操作工具类

为了避免在业务代码中到处写重复的连接和关闭逻辑,我们封装一个HBaseUtil

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.*;
import org.apache.hadoop.hbase.client.*;
import org.apache.hadoop.hbase.util.Bytes;
import java.io.IOException;
import java.util.List;

public class HBaseUtil {
    private static Connection connection = null;
    private static Configuration conf = HBaseConfiguration.create();

    static {
        // 关键配置:指向你的HBase集群ZooKeeper地址
        conf.set("hbase.zookeeper.quorum", "localhost");
        conf.set("hbase.zookeeper.property.clientPort", "2181");
        try {
            connection = ConnectionFactory.createConnection(conf);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void createTable(String tableName, String... columnFamilies) throws IOException {
        try (Admin admin = connection.getAdmin()) {
            TableName tn = TableName.valueOf(tableName);
            if (!admin.tableExists(tn)) {
                HTableDescriptor tableDesc = new HTableDescriptor(tn);
                for (String cf : columnFamilies) {
                    tableDesc.addFamily(new HColumnDescriptor(cf));
                }
                admin.createTable(tableDesc);
                System.out.println("表 " + tableName + " 创建成功。");
            } else {
                System.out.println("表 " + tableName + " 已存在。");
            }
        }
    }

    /**
     * 批量写入数据,性能远高于单条写入
     * @param tableName 表名
     * @param puts Put对象列表
     * @return 写入耗时(毫秒)
     */
    public static long batchPut(String tableName, List<Put> puts) throws IOException {
        long start = System.currentTimeMillis();
        try (Table table = connection.getTable(TableName.valueOf(tableName))) {
            table.put(puts); // HBase客户端会自动缓冲和批量提交
        }
        return System.currentTimeMillis() - start;
    }

    // 获取连接,供其他需要复杂操作的方法使用
    public static Connection getConnection() {
        return connection;
    }

    public static void close() throws IOException {
        if (connection != null && !connection.isClosed()) {
            connection.close();
        }
    }
}

在数据采集程序中,你可以这样使用它:

// 初始化表
HBaseUtil.createTable("t_city_hotel", "cf_info", "cf_stat");

List<Put> putList = new ArrayList<>();
for (Hotel hotel : hotelList) {
    String rowKey = hotel.getCityId() + "_" + hotel.getId();
    Put put = new Put(Bytes.toBytes(rowKey));
    // 添加列族 cf_info 下的列
    put.addColumn(Bytes.toBytes("cf_info"), Bytes.toBytes("name"), Bytes.toBytes(hotel.getName()));
    put.addColumn(Bytes.toBytes("cf_info"), Bytes.toBytes("star"), Bytes.toBytes(hotel.getStar()));
    // 添加列族 cf_stat 下的列
    put.addColumn(Bytes.toBytes("cf_stat"), Bytes.toBytes("price"), Bytes.toBytes(String.valueOf(hotel.getPrice())));
    put.addColumn(Bytes.toBytes("cf_stat"), Bytes.toBytes("score"), Bytes.toBytes(String.valueOf(hotel.getScore())));
    putList.add(put);
}
// 批量写入
long cost = HBaseUtil.batchPut("t_city_hotel", putList);
System.out.println("成功写入 " + putList.size() + " 条记录,耗时 " + cost + " 毫秒。");

4. 核心分析:编写MapReduce作业挖掘数据价值

数据存好了,现在进入最核心的分析阶段。我们将编写两个MapReduce作业,分别计算各城市酒店平均价格和评论词频。

4.1 作业一:统计各城市酒店平均价格

这个作业的输入是HBase表t_city_hotel,输出到一个新的HBase表city_avg_price

Mapper类 (AvgPriceMapper):它的任务是从输入表中读出每一行数据,提取城市ID和价格,然后以城市ID为Key,价格为Value输出。

import org.apache.hadoop.hbase.client.Result;
import org.apache.hadoop.hbase.io.ImmutableBytesWritable;
import org.apache.hadoop.hbase.mapreduce.TableMapper;
import org.apache.hadoop.io.DoubleWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.hbase.util.Bytes;
import java.io.IOException;

public class AvgPriceMapper extends TableMapper<Text, DoubleWritable> {
    private Text outputKey = new Text();
    private DoubleWritable outputValue = new DoubleWritable();

    @Override
    protected void map(ImmutableBytesWritable key, Result value, Context context)
            throws IOException, InterruptedException {
        // 1. 从RowKey中解析城市ID。我们的RowKey是`城市ID_酒店ID`
        String rowKeyStr = Bytes.toString(key.get());
        String cityId = rowKeyStr.split("_")[0]; // 简单分割,实际需更健壮解析

        // 2. 从列族cf_stat的price列获取价格
        byte[] priceBytes = value.getValue(Bytes.toBytes("cf_stat"), Bytes.toBytes("price"));
        if (priceBytes != null) {
            try {
                double price = Double.parseDouble(Bytes.toString(priceBytes));
                outputKey.set(cityId);
                outputValue.set(price);
                // 3. 输出 <城市ID, 价格>
                context.write(outputKey, outputValue);
            } catch (NumberFormatException e) {
                // 记录脏数据,跳过
                context.getCounter("DataQuality", "INVALID_PRICE").increment(1);
            }
        } else {
            // 价格为空,跳过
            context.getCounter("DataQuality", "MISSING_PRICE").increment(1);
        }
    }
}

Reducer类 (AvgPriceReducer):接收同一个城市ID的所有价格,计算平均值,并将结果写回HBase。

import org.apache.hadoop.hbase.client.Put;
import org.apache.hadoop.hbase.io.ImmutableBytesWritable;
import org.apache.hadoop.hbase.mapreduce.TableReducer;
import org.apache.hadoop.hbase.util.Bytes;
import org.apache.hadoop.io.DoubleWritable;
import org.apache.hadoop.io.Text;
import java.io.IOException;

public class AvgPriceReducer extends TableReducer<Text, DoubleWritable, ImmutableBytesWritable> {
    @Override
    protected void reduce(Text key, Iterable<DoubleWritable> values, Context context)
            throws IOException, InterruptedException {
        double sum = 0.0;
        int count = 0;
        // 1. 求和与计数
        for (DoubleWritable val : values) {
            sum += val.get();
            count++;
        }
        // 2. 计算平均值
        double avgPrice = sum / count;

        // 3. 构建Put对象,RowKey就是城市ID
        Put put = new Put(Bytes.toBytes(key.toString()));
        // 将平均值放入列族'cf_result'的'avg_price'列
        put.addColumn(Bytes.toBytes("cf_result"), Bytes.toBytes("avg_price"),
                      Bytes.toBytes(String.valueOf(avgPrice)));
        put.addColumn(Bytes.toBytes("cf_result"), Bytes.toBytes("hotel_count"),
                      Bytes.toBytes(String.valueOf(count)));

        // 4. 输出到HBase表
        context.write(null, put);
    }
}

Driver类 (AvgPriceDriver):这是作业的入口,负责配置整个MapReduce任务,并指定输入输出表。

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.HBaseConfiguration;
import org.apache.hadoop.hbase.client.Scan;
import org.apache.hadoop.hbase.mapreduce.TableMapReduceUtil;
import org.apache.hadoop.io.DoubleWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.util.GenericOptionsParser;

public class AvgPriceDriver {
    public static void main(String[] args) throws Exception {
        Configuration conf = HBaseConfiguration.create();
        // 解析命令行参数
        String[] otherArgs = new GenericOptionsParser(conf, args).getRemainingArgs();

        Job job = Job.getInstance(conf, "City Hotel Average Price");
        job.setJarByClass(AvgPriceDriver.class);

        // 配置Mapper:从 t_city_hotel 表读取
        Scan scan = new Scan();
        scan.addFamily(Bytes.toBytes("cf_stat")); // 只扫描包含价格的列族,减少IO
        TableMapReduceUtil.initTableMapperJob(
                "t_city_hotel",       // 输入表
                scan,                 // 扫描器
                AvgPriceMapper.class, // Mapper类
                Text.class,           // Mapper输出Key类型
                DoubleWritable.class, // Mapper输出Value类型
                job
        );

        // 配置Reducer:输出到 city_avg_price 表
        TableMapReduceUtil.initTableReducerJob(
                "city_avg_price",     // 输出表
                AvgPriceReducer.class,// Reducer类
                job
        );
        // 设置Reducer数量
        job.setNumReduceTasks(1); // 数据量不大时,一个Reducer即可

        // 提交作业
        System.exit(job.waitForCompletion(true) ? 0 : 1);
    }
}

将这个作业打包成Jar文件,提交到Hadoop集群运行:

hadoop jar your-job.jar com.yourpackage.AvgPriceDriver

4.2 作业二:分析用户评论情感与关注点

评论词频分析能让我们知道用户最关心什么。这里需要一个中文分词库,我推荐使用HanLPAnsj,它们功能强大且易于集成。Mapper阶段需要从评论表中读取内容,进行分词;Reducer阶段统计每个词出现的总次数。

由于涉及第三方分词库,在打包和提交作业时需要特别注意,必须将分词库的Jar包及其依赖一起打包,或者通过-libjars参数指定。这是课程设计中最容易踩的坑之一。

Mapper类核心分词逻辑:

// 假设使用Ansj分词
import org.ansj.domain.Result;
import org.ansj.splitWord.analysis.ToAnalysis;
import org.ansj.domain.Term;

// 在map方法中:
String content = Bytes.toString(value.getValue(Bytes.toBytes("cf_content"), Bytes.toBytes("content")));
if (content != null && !content.trim().isEmpty()) {
    // 进行分词
    Result termResult = ToAnalysis.parse(content);
    for (Term term : termResult.getTerms()) {
        String word = term.getName();
        // 过滤掉无意义的词,如标点、单个字、停用词
        if (isMeaningfulWord(word)) {
            outputKey.set(word);
            context.write(outputKey, ONE);
        }
    }
}

打包技巧:使用Maven的maven-assembly-pluginmaven-shade-plugin制作一个包含所有依赖的“胖Jar”(Uber Jar),可以避免在集群上找不到类的错误。

5. 让数据说话:使用ECharts打造动态可视化看板

分析结果存储在HBase里,我们需要一个前端页面把它们直观地展示出来。ECharts是目前最优秀的数据可视化库之一。我们将创建一个简单的Spring Boot后端来提供数据API,前端用HTML+JavaScript调用ECharts绘图。

5.1 后端:提供RESTful API

首先,编写一个Service层从HBase的city_avg_price表中读取数据。

@Service
public class HotelDataService {
    public List<CityAvgPrice> getCityAvgPriceList() throws IOException {
        List<CityAvgPrice> result = new ArrayList<>();
        try (Table table = HBaseUtil.getConnection().getTable(TableName.valueOf("city_avg_price"))) {
            Scan scan = new Scan();
            try (ResultScanner scanner = table.getScanner(scan)) {
                for (Result r : scanner) {
                    CityAvgPrice item = new CityAvgPrice();
                    // 从RowKey获取城市ID
                    item.setCityId(Bytes.toString(r.getRow()));
                    // 从列获取平均价格和酒店数量
                    byte[] avgPriceBytes = r.getValue(Bytes.toBytes("cf_result"), Bytes.toBytes("avg_price"));
                    byte[] countBytes = r.getValue(Bytes.toBytes("cf_result"), Bytes.toBytes("hotel_count"));
                    if (avgPriceBytes != null) {
                        item.setAvgPrice(Double.parseDouble(Bytes.toString(avgPriceBytes)));
                    }
                    if (countBytes != null) {
                        item.setHotelCount(Integer.parseInt(Bytes.toString(countBytes)));
                    }
                    // TODO: 可以根据cityId去查询城市名称(需要另一张维表)
                    result.add(item);
                }
            }
        }
        // 按价格排序
        result.sort(Comparator.comparingDouble(CityAvgPrice::getAvgPrice).reversed());
        return result;
    }
}

@RestController
@RequestMapping("/api/data")
public class DataController {
    @Autowired
    private HotelDataService hotelDataService;

    @GetMapping("/cityAvgPrice")
    public ApiResponse<List<CityAvgPrice>> getCityAvgPrice() {
        try {
            return ApiResponse.success(hotelDataService.getCityAvgPriceList());
        } catch (IOException e) {
            return ApiResponse.error(500, "获取数据失败");
        }
    }
}

5.2 前端:使用ECharts绘制图表

在前端HTML中,我们引入ECharts库,然后通过AJAX调用后端API获取数据,并渲染图表。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>酒店数据分析看板</title>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    <style>
        .chart-container { width: 100%; height: 500px; margin: 20px auto; }
    </style>
</head>
<body>
    <h2>各城市酒店平均价格对比</h2>
    <div id="priceChart" class="chart-container"></div>

    <script>
        // 初始化图表实例
        const priceChart = echarts.init(document.getElementById('priceChart'));

        // 从后端API获取数据
        fetch('/api/data/cityAvgPrice')
            .then(response => response.json())
            .then(apiResult => {
                if (apiResult.code === 200) {
                    const data = apiResult.data;
                    // 准备ECharts需要的数据格式
                    const cityNames = data.map(item => `城市${item.cityId}`); // 实际应替换为城市名
                    const avgPrices = data.map(item => item.avgPrice.toFixed(2));
                    const hotelCounts = data.map(item => item.hotelCount);

                    // 配置项
                    const option = {
                        tooltip: {
                            trigger: 'axis',
                            axisPointer: { type: 'shadow' }
                        },
                        legend: {
                            data: ['平均价格', '酒店数量']
                        },
                        xAxis: {
                            type: 'category',
                            data: cityNames,
                            axisLabel: { rotate: 45 } // 城市名太长时旋转
                        },
                        yAxis: [
                            {
                                type: 'value',
                                name: '平均价格(元)',
                                position: 'left'
                            },
                            {
                                type: 'value',
                                name: '酒店数量',
                                position: 'right'
                            }
                        ],
                        series: [
                            {
                                name: '平均价格',
                                type: 'bar',
                                data: avgPrices,
                                itemStyle: {
                                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                                        { offset: 0, color: '#83bff6' },
                                        { offset: 0.5, color: '#188df0' },
                                        { offset: 1, color: '#188df0' }
                                    ])
                                }
                            },
                            {
                                name: '酒店数量',
                                type: 'line',
                                yAxisIndex: 1, // 使用右侧Y轴
                                data: hotelCounts,
                                symbol: 'circle',
                                lineStyle: { color: '#f05b72' },
                                itemStyle: { color: '#f05b72' }
                            }
                        ]
                    };
                    // 使用配置项渲染图表
                    priceChart.setOption(option);
                } else {
                    console.error('获取数据失败:', apiResult.message);
                }
            })
            .catch(error => console.error('请求失败:', error));

        // 响应窗口大小变化
        window.addEventListener('resize', () => {
            priceChart.resize();
        });
    </script>
</body>
</html>

这样,一个包含柱状图(平均价格)和折线图(酒店数量)的双Y轴混合图表就完成了。你可以用同样的方法,为词频分析结果制作一个词云图条形图,让高频词汇一目了然。

整个项目做下来,你会对大数据处理的各个环节有一个非常扎实的体验。从环境搭建、数据采集、存储设计、分布式计算到可视化展示,这套流程在企业级的简单数据分析场景中也是相通的。课程设计的目的不是做出多炫酷的系统,而是打通从数据到价值的完整链条,理解每个技术选型背后的原因,并积累解决实际问题的经验。希望这份指南能帮你少走弯路,高效地完成一个高质量的大数据课程设计。如果在实践过程中遇到具体问题,比如某个配置项找不到,或者MapReduce作业卡住了,不妨多看看日志,那里面通常藏着答案。

更多推荐