关键词:
在本篇分享中,将介绍一个完整的项目案例,该案例会真实还原企业中SparkSQL的开发流程,手把手教你构建一个基于SparkSQL的分析系统。为了讲解方便,我会对代码进行拆解,完整的代码已上传至GitHub,想看完整代码可以去clone,顺便给个**Star**。以下是全文,希望本文对你有所帮助。看完记得三连:分享、点赞、在看
https://github.com/jiamx/spark_project_practise
项目介绍
数据集介绍
使用MovieLens的名称为ml-25m.zip的数据集,使用的文件时movies.csv和ratings.csv,上述文件的下载地址为:
http://files.grouplens.org/datasets/movielens/ml-25m.zip
- movies.csv
该文件是电影数据,对应的为维表数据,大小为2.89MB,包括6万多部电影,其数据格式为[movieId,title,genres],分别对应[电影id,电影名称,电影所属分类],样例数据如下所示:逗号分隔
1,Toy Story (1995),Adventure|Animation|Children|Comedy|Fantasy
- ratings.csv
该文件为定影评分数据,对应为事实表数据,大小为646MB,其数据格式为:[userId,movieId,rating,timestamp],分别对应[用户id,电影id,评分,时间戳],样例数据如下所示:逗号分隔
1,296,5,1147880044
项目代码结构
需求分析
- 需求1:查找电影评分个数超过5000,且平均评分较高的前十部电影名称及其对应的平均评分
- 需求2:查找每个电影类别及其对应的平均评分
- 需求3:查找被评分次数较多的前十部电影
代码讲解
- DemoMainApp
该类是程序执行的入口,主要是获取数据源,转换成DataFrame,并调用封装好的业务逻辑类。
object DemoMainApp
// 文件路径
private val MOVIES_CSV_FILE_PATH = "file:///e:/movies.csv"
private val RATINGS_CSV_FILE_PATH = "file:///e:/ratings.csv"
def main(args: Array[String]): Unit =
// 创建spark session
val spark = SparkSession
.builder
.master("local[4]")
.getOrCreate
// schema信息
val schemaLoader = new SchemaLoader
// 读取Movie数据集
val movieDF = readCsvIntoDataSet(spark, MOVIES_CSV_FILE_PATH, schemaLoader.getMovieSchema)
// 读取Rating数据集
val ratingDF = readCsvIntoDataSet(spark, RATINGS_CSV_FILE_PATH, schemaLoader.getRatingSchema)
// 需求1:查找电影评分个数超过5000,且平均评分较高的前十部电影名称及其对应的平均评分
val bestFilmsByOverallRating = new BestFilmsByOverallRating
//bestFilmsByOverallRating.run(movieDF, ratingDF, spark)
// 需求2:查找每个电影类别及其对应的平均评分
val genresByAverageRating = new GenresByAverageRating
//genresByAverageRating.run(movieDF, ratingDF, spark)
// 需求3:查找被评分次数较多的前十部电影
val mostRatedFilms = new MostRatedFilms
mostRatedFilms.run(movieDF, ratingDF, spark)
spark.close()
/**
* 读取数据文件,转成DataFrame
*
* @param spark
* @param path
* @param schema
* @return
*/
def readCsvIntoDataSet(spark: SparkSession, path: String, schema: StructType) =
val dataSet = spark.read
.format("csv")
.option("header", "true")
.schema(schema)
.load(path)
dataSet
- Entry
该类为实体类,封装了数据源的样例类和结果表的样例类
class Entry
case class Movies(
movieId: String, // 电影的id
title: String, // 电影的标题
genres: String // 电影类别
)
case class Ratings(
userId: String, // 用户的id
movieId: String, // 电影的id
rating: String, // 用户评分
timestamp: String // 时间戳
)
// 需求1MySQL结果表
case class tenGreatestMoviesByAverageRating(
movieId: String, // 电影的id
title: String, // 电影的标题
avgRating: String // 电影平均评分
)
// 需求2MySQL结果表
case class topGenresByAverageRating(
genres: String, //电影类别
avgRating: String // 平均评分
)
// 需求3MySQL结果表
case class tenMostRatedFilms(
movieId: String, // 电影的id
title: String, // 电影的标题
ratingCnt: String // 电影被评分的次数
)
- SchemaLoader
该类封装了数据集的schema信息,主要用于读取数据源是指定schema信息
class SchemaLoader
// movies数据集schema信息
private val movieSchema = new StructType()
.add("movieId", DataTypes.StringType, false)
.add("title", DataTypes.StringType, false)
.add("genres", DataTypes.StringType, false)
// ratings数据集schema信息
private val ratingSchema = new StructType()
.add("userId", DataTypes.StringType, false)
.add("movieId", DataTypes.StringType, false)
.add("rating", DataTypes.StringType, false)
.add("timestamp", DataTypes.StringType, false)
def getMovieSchema: StructType = movieSchema
def getRatingSchema: StructType = ratingSchema
- JDBCUtil
该类封装了连接MySQL的逻辑,主要用于连接MySQL,在业务逻辑代码中会使用该工具类获取MySQL连接,将结果数据写入到MySQL中。
object JDBCUtil
val dataSource = new ComboPooledDataSource()
val user = "root"
val password = "123qwe"
val url = "jdbc:mysql://localhost:3306/mydb"
dataSource.setUser(user)
dataSource.setPassword(password)
dataSource.setDriverClass("com.mysql.jdbc.Driver")
dataSource.setJdbcUrl(url)
dataSource.setAutoCommitOnClose(false)
// 获取连接
def getQueryRunner(): Option[QueryRunner]=
try
Some(new QueryRunner(dataSource))
catch
case e:Exception =>
e.printStackTrace()
None
需求1实现
- BestFilmsByOverallRating
需求1实现的业务逻辑封装。该类有一个run()方法,主要是封装计算逻辑。
/**
* 需求1:查找电影评分个数超过5000,且平均评分较高的前十部电影名称及其对应的平均评分
*/
class BestFilmsByOverallRating extends Serializable
def run(moviesDataset: DataFrame, ratingsDataset: DataFrame, spark: SparkSession) =
import spark.implicits._
// 将moviesDataset注册成表
moviesDataset.createOrReplaceTempView("movies")
// 将ratingsDataset注册成表
ratingsDataset.createOrReplaceTempView("ratings")
// 查询SQL语句
val ressql1 =
"""
|WITH ratings_filter_cnt AS (
|SELECT
| movieId,
| count( * ) AS rating_cnt,
| avg( rating ) AS avg_rating
|FROM
| ratings
|GROUP BY
| movieId
|HAVING
| count( * ) >= 5000
|),
|ratings_filter_score AS (
|SELECT
| movieId, -- 电影id
| avg_rating -- 电影平均评分
|FROM ratings_filter_cnt
|ORDER BY avg_rating DESC -- 平均评分降序排序
|LIMIT 10 -- 平均分较高的前十部电影
|)
|SELECT
| m.movieId,
| m.title,
| r.avg_rating AS avgRating
|FROM
| ratings_filter_score r
|JOIN movies m ON m.movieId = r.movieId
""".stripMargin
val resultDS = spark.sql(ressql1).as[tenGreatestMoviesByAverageRating]
// 打印数据
resultDS.show(10)
resultDS.printSchema()
// 写入MySQL
resultDS.foreachPartition(par => par.foreach(insert2Mysql(_)))
/**
* 获取连接,调用写入MySQL数据的方法
*
* @param res
*/
private def insert2Mysql(res: tenGreatestMoviesByAverageRating): Unit =
lazy val conn = JDBCUtil.getQueryRunner()
conn match
case Some(connection) =>
upsert(res, connection)
case None =>
println("Mysql连接失败")
System.exit(-1)
/**
* 封装将结果写入MySQL的方法
* 执行写入操作
*
* @param r
* @param conn
*/
private def upsert(r: tenGreatestMoviesByAverageRating, conn: QueryRunner): Unit =
try
val sql =
s"""
|REPLACE INTO `ten_movies_averagerating`(
|movieId,
|title,
|avgRating
|)
|VALUES
|(?,?,?)
""".stripMargin
// 执行insert操作
conn.update(
sql,
r.movieId,
r.title,
r.avgRating
)
catch
case e: Exception =>
e.printStackTrace()
System.exit(-1)
需求1结果
- 结果表建表语句
CREATE TABLE `ten_movies_averagerating` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增id',
`movieId` int(11) NOT NULL COMMENT '电影id',
`title` varchar(100) NOT NULL COMMENT '电影名称',
`avgRating` decimal(10,2) NOT NULL COMMENT '平均评分',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `movie_id_UNIQUE` (`movieId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
- 统计结果
平均评分最高的前十部电影如下:
movieId | title | avgRating |
---|---|---|
318 | Shawshank Redemption, The (1994) | 4.41 |
858 | Godfather, The (1972) | 4.32 |
50 | Usual Suspects, The (1995) | 4.28 |
1221 | Godfather: Part II, The (1974) | 4.26 |
527 | Schindler’s List (1993) | 4.25 |
2019 | Seven Samurai (Shichinin no samurai) (1954) | 4.25 |
904 | Rear Window (1954) | 4.24 |
1203 | 12 Angry Men (1957) | 4.24 |
2959 | Fight Club (1999) | 4.23 |
1193 | One Flew Over the Cuckoo’s Nest (1975) | 4.22 |
上述电影评分对应的电影中文名称为:
英文名称 | 中文名称 |
---|---|
Shawshank Redemption, The (1994) | 肖申克的救赎 |
Godfather, The (1972) | 教父1 |
Usual Suspects, The (1995) | 非常嫌疑犯 |
Godfather: Part II, The (1974) | 教父2 |
Schindler’s List (1993) | 辛德勒的名单 |
Seven Samurai (Shichinin no samurai) (1954) | 七武士 |
Rear Window (1954) | 后窗 |
12 Angry Men (1957) | 十二怒汉 |
Fight Club (1999) | 搏击俱乐部 |
One Flew Over the Cuckoo’s Nest (1975) | 飞越疯人院 |
需求2实现
- GenresByAverageRating
需求2实现的业务逻辑封装。该类有一个run()方法,主要是封装计算逻辑。
**
* 需求2:查找每个电影类别及其对应的平均评分
*/
class GenresByAverageRating extends Serializable
def run(moviesDataset: DataFrame, ratingsDataset: DataFrame, spark: SparkSession) =
import spark.implicits._
// 将moviesDataset注册成表
moviesDataset.createOrReplaceTempView("movies")
// 将ratingsDataset注册成表
ratingsDataset.createOrReplaceTempView("ratings")
val ressql2 =
"""
|WITH explode_movies AS (
|SELECT
| movieId,
| title,
| category
|FROM
| movies lateral VIEW explode ( split ( genres, "\\\\|" ) ) temp AS category
|)
|SELECT
| m.category AS genres,
| avg( r.rating ) AS avgRating
|FROM
| explode_movies m
| JOIN ratings r ON m.movieId = r.movieId
|GROUP BY
| m.category
| """.stripMargin
val resultDS = spark.sql(ressql2).as[topGenresByAverageRating]
// 打印数据
resultDS.show(10)
resultDS.printSchema()
// 写入MySQL
resultDS.foreachPartition(par => par.foreach(insert2Mysql(_)))
/**
* 获取连接,调用写入MySQL数据的方法
*
* @param res
*/
private def insert2Mysql(res: topGenresByAverageRating): Unit =
lazy val conn = JDBCUtil.getQueryRunner()
conn match
case Some(connection) =>
upsert(res, connection)
case None =>
println("Mysql连接失败")
System.exit(-1)
/**
* 封装将结果写入MySQL的方法
* 执行写入操作
*
* @param r
* @param conn
*/
private def upsert(r: topGenresByAverageRating, conn: QueryRunner): Unit =
try
val sql =
s"""
|REPLACE INTO `genres_average_rating`(
|genres,
|avgRating
|)
|VALUES
|(?,?)
""".stripMargin
// 执行insert操作
conn.update(
sql,
r.genres,
r.avgRating
)
catch
case e: Exception =>
e.printStackTrace()
System.exit(-1)
需求2结果
- 结果表建表语句
CREATE TABLE genres_average_rating (
`id` INT ( 11 ) NOT NULL AUTO_INCREMENT COMMENT '自增id',
`genres` VARCHAR ( 100 ) NOT NULL COMMENT '电影类别',
`avgRating` DECIMAL ( 10, 2 ) NOT NULL COMMENT '电影类别平均评分',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY ( `id` ),
UNIQUE KEY `genres_UNIQUE` ( `genres` )
) ENGINE = INNODB DEFAULT CHARSET = utf8;
- 统计结果
共有20个电影分类,每个电影分类的平均评分为:
genres | avgRating |
---|---|
Film-Noir | 3.93 |
War | 3.79 |
Documentary | 3.71 |
Crime | 3.69 |
Drama | 3.68 |
Mystery | 3.67 |
Animation | 3.61 |
IMAX | 3.6 |
Western | 3.59 |
Musical | 3.55 |
Romance | 3.54 |
Adventure | 3.52 |
Thriller | 3.52 |
Fantasy | 3.51 |
Sci-Fi | 3.48 |
Action | 3.47 |
Children | 3.43 |
Comedy | 3.42 |
(no genres listed) | 3.33 |
Horror | 3.29 |
电影分类对应的中文名称为:
分类 | 中文名称 |
---|---|
Film-Noir | 黑色电影 |
War | 战争 |
Documentary | 纪录片 |
Crime | 犯罪 |
Drama | 历史剧 |
Mystery | 推理 |
Animation | 动画片 |
IMAX | 巨幕电影 |
Western | 西部电影 |
Musical | 音乐 |
Romance | 浪漫 |
Adventure | 冒险 |
Thriller | 惊悚片 |
Fantasy | 魔幻电影 |
Sci-Fi | 科幻 |
Action | 动作 |
Children | 儿童 |
Comedy | 喜剧 |
(no genres listed) | 未分类 |
Horror | 恐怖 |
需求3实现
-
MostRatedFilms
需求3实现的业务逻辑封装。该类有一个run()方法,主要是封装计算逻辑。
/**
* 需求3:查找被评分次数较多的前十部电影.
*/
class MostRatedFilms extends Serializable
def run(moviesDataset: DataFrame, ratingsDataset: DataFrame,spark: SparkSession) =
import spark.implicits._
// 将moviesDataset注册成表
moviesDataset.createOrReplaceTempView("movies")
// 将ratingsDataset注册成表
ratingsDataset.createOrReplaceTempView("ratings")
val ressql3 =
"""
|WITH rating_group AS (
| SELECT
| movieId,
| count( * ) AS ratingCnt
| FROM ratings
| GROUP BY movieId
|),
|rating_filter AS (
| SELECT
| movieId,
| ratingCnt
| FROM rating_group
| ORDER BY ratingCnt DESC
| LIMIT 10
|)
|SELECT
| m.movieId,
| m.title,
| r.ratingCnt
|FROM
| rating_filter r
|JOIN movies m ON r.movieId = m.movieId
|
""".stripMargin
val resultDS = spark.sql(ressql3).as[tenMostRatedFilms]
// 打印数据
resultDS.show(10)
resultDS.printSchema()
// 写入MySQL
resultDS.foreachPartition(par => par.foreach(insert2Mysql(_)))
/**
* 获取连接,调用写入MySQL数据的方法
*
* @param res
*/
private def insert2Mysql(res: tenMostRatedFilms): Unit =
lazy val conn = JDBCUtil.getQueryRunner()
conn match
case Some(connection) =>
upsert(res, connection)
case None =>
println("Mysql连接失败")
System.exit(-1)
/**
* 封装将结果写入MySQL的方法
* 执行写入操作
*
* @param r
* @param conn
*/
private def upsert(r: tenMostRatedFilms, conn: QueryRunner): Unit =
try
val sql =
s"""
|REPLACE INTO `ten_most_rated_films`(
|movieId,
|title,
|ratingCnt
|)
|VALUES
|(?,?,?)
""".stripMargin
// 执行insert操作
conn.update(
sql,
r.movieId,
r.title,
r.ratingCnt
)
catch
case e: Exception =>
e.printStackTrace()
System.exit(-1)
需求3结果
- 结果表创建语句
CREATE TABLE ten_most_rated_films (
`id` INT ( 11 ) NOT NULL AUTO_INCREMENT COMMENT '自增id',
`movieId` INT ( 11 ) NOT NULL COMMENT '电影Id',
`title` varchar(100) NOT NULL COMMENT '电影名称',
`ratingCnt` INT(11) NOT NULL COMMENT '电影被评分的次数',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY ( `id` ),
UNIQUE KEY `movie_id_UNIQUE` ( `movieId` )
) ENGINE = INNODB DEFAULT CHARSET = utf8;
- 统计结果
movieId | title | ratingCnt |
---|---|---|
356 | Forrest Gump (1994) | 81491 |
318 | Shawshank Redemption, The (1994) | 81482 |
296 | Pulp Fiction (1994) | 79672 |
593 | Silence of the Lambs, The (1991) | 74127 |
2571 | Matrix, The (1999) | 72674 |
260 | Star Wars: Episode IV - A New Hope (1977) | 68717 |
480 | Jurassic Park (1993) | 64144 |
527 | Schindler’s List (1993) | 60411 |
110 | Braveheart (1995) | 59184 |
2959 | Fight Club (1999) | 58773 |
评分次数较多的电影对应的中文名称为:
英文名称 | 中文名称 |
---|---|
Forrest Gump (1994) | 阿甘正传 |
Shawshank Redemption, The (1994) | 肖申克的救赎 |
Pulp Fiction (1994) | 低俗小说 |
Silence of the Lambs, The (1991) | 沉默的羔羊 |
Matrix, The (1999) | 黑客帝国 |
Star Wars: Episode IV - A New Hope (1977) | 星球大战 |
Jurassic Park (1993) | 侏罗纪公园 |
Schindler’s List (1993) | 辛德勒的名单 |
Braveheart (1995) | 勇敢的心 |
Fight Club (1999) | 搏击俱乐部 |
总结
本文主要是基于SparkSQL对MovieLens数据集进行统计分析,完整实现了三个需求,并给对每个需求都给出了详细的代码实现和结果分析。本案例还原了企业使用SparkSQL进行实现数据统计的基本流程,通过本文,或许你对SparkSQL的应用有了更加深刻的认识,希望本文对你有所帮助。
基于python的电影数据可视化分析与推荐系统(代码片段)
...评论进行关键词抽取和情感分析。2.功能组成 基于python的电影数据可视化分析系统的功能组成如下图所示:3.基于python的电影数据可视化分析与推荐系统3.1系统注册登录 系统的其他页面的访问需要注册... 查看详情
毕业设计之-题目:基于大数据的电影数据分析可视化系统(代码片段)
文章目录1前言2项目介绍3效果展示4项目分析4.1爬虫部分4.2数据分析部分5最后-毕设帮助1前言Hi,大家好,这里是丹成学长,今天做一个电商销售预测分析,这只是一个demo,尝试对电影数据进行分析,并可... 查看详情
基于pandasmatplotlib和seaborn进行数据分析实战建议收藏(代码片段)
项目来源:https://www.kaggle.com/anthonypino/melbourne-housing-market项目简介:利用以往的房屋销售信息,分析哪种房屋最值得推荐给投资者进行投资。PS:本次项目是在jupyter上运行的。(文末附资源链接)导入模块:... 查看详情
django基于用户画像的电影推荐系统源码(项目源代码)(代码片段)
...;以从豆瓣平台爬取的电影数据作为基础数据源,主要基于用户的基本信息和使用操作记录等行为信息来开发用户标签,并使用Hadoop、Spark大数据组件进行分析和处理的推荐系统。管 查看详情
推荐系统入门到项目实战:基于相似度推荐(含代码)(代码片段)
...些学习推荐系统领域的方法和代码实现。推荐系统:基于相似度推荐(含代码)今天我们考虑一个很简单的问题,假设上线了两部新电影《黑豹2》和《简爱》,我们要给一个用户推荐一部电影看,我们很... 查看详情
推荐系统案例基于协同过滤的电影推荐(代码片段)
案例--基于协同过滤的电影推荐1.数据集下载2.数据集加载3.相似度计算4.User-BasedCF预测评分算法实现5.Item-BasedCF预测评分算法实现前面我们已经基本掌握了协同过滤推荐算法,以及其中两种最基本的实现方案:User-BasedCF和It... 查看详情
200.spark:sparksql项目实战(代码片段)
一、启动环境需要启动mysql,hadoop,hive,spark。并且能让spark连接上hive(上一章有讲)#启动mysql,并登录,密码123456sudosystemctlstartmysqldmysql-uroot-p#启动hivecd/opt/module/myhadoop.shstart#查看启动情况jpsall#启动hivecd/opt/module/hive/bin/hiveservice... 查看详情
自然语言处理(nlp)基于fnn网络的电影评论情感分析(代码片段)
【自然语言处理(NLP)】基于FNN网络的电影评论情感分析作者简介:在校大学生一枚,华为云享专家,阿里云专家博主,腾云先锋(TDP)成员,云曦智划项目总负责人,全国高等学校计算机... 查看详情
毕业设计-基于大数据的电影爬取与可视化分析系统-python(代码片段)
...家好,这里是海浪学长毕设专题,本次分享的课题是🎯基于大数据的电影爬取与可视化分析系统课题背景和意义随着信息技术的发展,爬取和可视化分析系统作为一种重要的数据获取和分析方法,已经得到了广泛的应用... 查看详情
概念+实战讲解!一文带你了解rfm模型kaggle项目实战分享数据分析(代码片段)
...频率M值:Monetary,消费金额二、实践应用有哪些?基于RFM模型进行客户细分通过RFM模型评分后输出目标用户基于RFM的常用策略补充三、kaggle项目实战讲解1数据探 查看详情
sparksql源码详细分析(代码片段)
SparkSql源码分析文章目录SparkSql源码分析一、SparkSQL架构设计二、代码分析1、Demo2、Catalyst执行过程三、执行计划分析1、sql解析阶段Parser2、绑定逻辑计划Analyzer3、逻辑优化阶段Optimizer4、生成可执行的物理计划阶段PhysicalPlan5、代码... 查看详情
sparksql源码详细分析(代码片段)
SparkSql源码分析文章目录SparkSql源码分析一、SparkSQL架构设计二、代码分析1、Demo2、Catalyst执行过程三、执行计划分析1、sql解析阶段Parser2、绑定逻辑计划Analyzer3、逻辑优化阶段Optimizer4、生成可执行的物理计划阶段PhysicalPlan5、代码... 查看详情
基于ssm框架电影订票网站开发全程实录(附源码)(代码片段)
...调试并解决后期项目运行问题3.项目简介电影售票系统是基于web的电影购票网站,注册登录用户可在线浏览热映电影信息,电影排行榜,查看附近影院,支持在线购票,实时支付等。该系统UI界面简洁大方,... 查看详情
基于springboot实现操作gaussdb(dws)的项目实战(代码片段)
...现对GaussDB(DWS)的增删改查操作。本文分享自华为云社区《基于SpringBoot实现操作GaussDB(DWS)的项目实战【玩转PB级数仓GaussDB(DWS)】》,作者:清雨小竹。GaussDB(DWS)数据仓库服务GaussDB(DWS)是一种基于华为云基础架构和平台的在... 查看详情
项目实战(gvrp链路捆绑)(代码片段)
实验需求实验需求:1.PC-1/2/3基于需求进行编址;2.PC-1与PC-3互通;3.PC-2与PC-3互通;4.PC-1与PC-2不通;5.交换机之间动态学习VLAN信息;6.实现SW2与SW3之间连接的强壮性;7.IP地址规划PC-1:192.168.1.1/25PC-2:192.168.1.2/26PC-3:192.168.1.3/27实... 查看详情
基于ssm框架的电影院购票系统(代码片段)
今天,博主分享一份基于SSM框架与maven管理的电影院购票系统项目结构该项目基本完成了电影购票流程内的所有功能,项目前端使用ajax进行请求,json数据流返还结果,是一份较为完善的项目技术简介基础框架... 查看详情
基于hive的youtube电影数据分析(代码片段)
文章目录一、项目需求二、数据介绍三、创建表结构四、数据清洗五、数据加载六、业务数据分析数据链接:链接:https://pan.baidu.com/s/10P1Bmjx-y17R8jmy4q685g提取码:79a0一、项目需求1.统计视频观看数Top102.统计视频类别热度Top1... 查看详情
c++项目实战银行信息管理系统分析及其实现(代码片段)
...次想玩玩QT)。为了兼顾两者,所以最终就定了个基于文件管理的版本。哈哈哈,QT的版本等验收之后再发(虽然我还没开始写)当然,如果不追求美感的话,加个 查看详情