当前位置: 首页 > news >正文

Java小型项目-音乐评论分析

项目介绍

前言

在实际开发过程中,在我们动手开发之前,都是由产品经理为我们(测试、前端、后端、项目经理等)先讲解一下需求,我们了解了需求之后,才开始一起来讨论技术方案。

项目流程

我们自己实现一些小功能时同样需要讨论需求,也就是告诉别人我们为什么要做这个东西?或者我们想利用这款产品解决什么问题。

一、项目需求

大家每天都离不开音乐,平时听歌时,有些歌的评论往往句句扎心,在评论中,最上面的是热门评论,现在想知道每个歌手的歌曲评论中出现最频繁的词语是什么。

二、功能描述

2.1 抓取单曲

采用爬虫+数据分析的方式,抓取指定歌手的热门单曲:

image.png

2.2 抓取评论

可以看到张信哲的热门单曲有50首,再抓取每一个首歌的热门评论:

image.png

2.3 分词

将热门评论记录内容,对评论用工具进行分词,然后按照词汇出现的频率,频率越高字体越大,呈现词云结果,最后的效果如下:

张信哲

zhangxinze.png

其它歌手:

邓紫棋

dengziqi.png

田馥甄

tianfuzhen.png

三、技术方案

把上面的需求梳理,整合成项目技术方案,如下列表所示:

  1. 自动查询某个歌手的所有热门歌曲
  2. 自动获取每一首歌的基础信息,专辑信息
  3. 自动获取每一首歌的热门评论,最新评论
  4. 对所有的热门评论进行统计形成词云

歌单信息

获取热门歌单

认识API

首先第一步需要获取某个歌手的歌单信息。

查询某个歌手的歌单信息API描述信息如下:

信息描述信息说明
API地址http://neteaseapi.youkeda.com:3000/artists?id=xxx
请求方式GET
参数说明id - 歌手ID

从上面的API可以看出,如果有歌手ID,便能够查询到歌手所有的单曲,比如:周杰伦在网易云音乐上的ID 6452

组装成的完整URL为: http://neteaseapi.youkeda.com:3000/artists?id=6452

在浏览器中可以访问这个地址,可以看到返回的数据是Json格式,大致为:

{"artist": {"img1v1Id": 109951163111191410,"topicPerson": 0,"alias": ["Jay Chou"],"trans": "","picUrl": "https://p1.music.126.net/ql3nSwy0XKow_HAoZzRZgw==/109951163111196186.jpg","followed": false,"picId": 109951163111196200,"albumSize": 35,"briefDesc": "著名歌手,音乐人,词曲创作人,编曲及制作人,MV及电影导演。新世纪华语歌坛领军人物,中国风歌曲始祖,被时代周刊誉为“亚洲猫王”,是2000年后亚洲流行乐坛最具革命性与指标性的创作歌手,亚洲销量超过3100万张,有“亚洲流行天王”之称,开启华语乐坛“R&B时代”与“流行乐中国风”的先河,周杰伦的出现打破了亚洲流行乐坛长年停滞不前的局面,为亚洲流行乐坛翻开了新的一页,是华语乐坛真正把R&B提升到主流高度的人物,引领华语乐坛革命整十年,改写了华语乐坛的流行方向。","musicSize": 488,"img1v1Url": "https://p1.music.126.net/o-FjCrUlhyFC96xiVvJZ8g==/109951163111191410.jpg","name": "周杰伦","id": 6452,"publishTime": 1516594084751,"picId_str": "109951163111196186","img1v1Id_str": "109951163111191410","mvSize": 8},"hotSongs": [……],"more": true,"code": 200
}

从返回结果中可以看出两个重要数据:

  1. artist 是歌手的歌单(也叫“专辑”,这里统一称歌单)数据,里面包含了歌手信息,歌手名称,别名,简介,歌曲书,专辑数等
  2. hotSongs 值的格式是:[],表示歌曲数据集合。一个歌单包含一组歌曲。每个歌曲有id,名称等属性。

由于歌曲的属性字段太多,为了避免干扰就不列出来了。

如何查询歌手ID

在网易云音乐中搜索歌手,进入歌手主页,如下图所示,链接中红框便是歌手ID

歌手主页

开发阶段核心步骤

1.需求分析

从以上对歌单数据的分析,可以得出关键模型(定义对象及其属性)是:

  • 歌单
  • 歌曲

要完成数据爬取,需要搭配的服务(定义操作、行为)是:

  • 歌单服务接口
  • 歌单服务实现类

模型+服务 适合很多 Java 程序的场景,是一种通用的思路,这点务必要记住并学会应用。

2.概要设计

概要设计阶段是完成 模型+服务 的整体结构。

这是一个比较经典的结构。以后大家在遇到复杂的场景时,区别只是需要设计更多的模型和更多的服务而已。

设计规范:

  • service 包存放服务接口,其子包 impl 存放服务实现类。
  • model 包存放模型。

3.项目依赖

请看在 pom.xml 文件中添加依赖的写法:

<dependencies><dependency><groupId>com.squareup.okhttp3</groupId>      <artifactId>okhttp</artifactId><version>4.1.0</version></dependency><dependency><groupId>com.alibaba</groupId>      <artifactId>fastjson</artifactId><version>1.2.62</version></dependency>
</dependencies>

根据经验,预先添加必要的依赖库。当然也可以在项目开发过程中添加。

程序如果编译、运行出错,就有必要检查依赖库是否完整、正确。

模型设计

歌单及歌曲模型详细设计

回顾周杰伦的歌单(http://neteaseapi.youkeda.com:3000/artists?id=6452)数据:

在浏览器中访问这个地址可以看到返回的数据是Json格式:

{"artist": {"img1v1Id": 109951163111191410,"topicPerson": 0,"alias": ["Jay Chou"],"trans": "","picUrl": "https://p1.music.126.net/ql3nSwy0XKow_HAoZzRZgw==/109951163111196186.jpg","followed": false,"picId": 109951163111196200,"albumSize": 35,"briefDesc": "著名歌手,音乐人,词曲创作人,编曲及制作人,MV及电影导演。新世纪华语歌坛领军人物,中国风歌曲始祖,被时代周刊誉为“亚洲猫王”,是2000年后亚洲流行乐坛最具革命性与指标性的创作歌手,亚洲销量超过3100万张,有“亚洲流行天王”之称,开启华语乐坛“R&B时代”与“流行乐中国风”的先河,周杰伦的出现打破了亚洲流行乐坛长年停滞不前的局面,为亚洲流行乐坛翻开了新的一页,是华语乐坛真正把R&B提升到主流高度的人物,引领华语乐坛革命整十年,改写了华语乐坛的流行方向。","musicSize": 488,"img1v1Url": "https://p1.music.126.net/o-FjCrUlhyFC96xiVvJZ8g==/109951163111191410.jpg","name": "周杰伦","id": 6452,"publishTime": 1516594084751,"picId_str": "109951163111196186","img1v1Id_str": "109951163111191410","mvSize": 8},"hotSongs": [……],"more": true,"code": 200
}

分析

第一层 artist 是歌单;第二层是歌单的各个数据字段。歌单以下字段比较有用:

字段作用、含义
id歌单的唯一id
alias别名。明星可能有各种名字,包括英文名、艺名等,所以这里是一组。
picUrl封面图。
briefDesc艺人介绍
img1v1Url正方形封面图。适合展示歌单列表等场景
name(艺人的)名称

当然,有可能你会觉得其它字段也比较有用需要抓取,没关系,也可以写到代码里。

并列第一层 hotSongs 是歌曲数据。歌曲的字段太多了,但比较有用的字段是:

字段作用、含义
id歌曲的唯一id
name歌曲的名称

设计

在确定了需要的属性字段后,就可以完成模型图了:

扩展知识点

Java 用来定义模型的类,叫 POJO (Plain Ordinary Java Object)类。

在《Java面向对象》课程第一章(Java 面向对象)第四节(封装)已经讲过对象封装的规范(不熟悉的同学可以复习一下)。在此基础上,没有业务逻辑、即不允许有增、删、改、查等操作数据的方法的类,叫作 POJO 类。

服务设计

服务的接口及其实现,组成了操作数据的核心能力。

所谓数据,就是上一节学习的 POJO 模型,那么服务就是为了完成业务逻辑。

分析

一、抓取

项目目标是提供抓取歌单的服务,给其它类调用,是一个通用能力,让具体的业务根据需要抓取歌单。那么就需要抓取歌单数据的方法:

  • 既然是给其它类调用,那么应该定义成 public
  • 这个方法不需要返回值,只是提供抓取功能,所以方法返回值定义为 void
  • 方法的作用是开始执行抓取任务,可以命名为 start 。注意方法名一定要代表功能含义,让别人阅读时能理解是做什么的
  • 方法的参数当然是字符串类型的歌单ID。本章第一节讲了,歌单其实是属于某位歌手的,所以这里命名为 artistId 。
    • 两个单词,第二个单词首字母大写。Id 是唯一的编号。
  public void start(String artistId);

作为核心服务,必须具备良好的扩展性,能支持服务调用者抓取任意歌单,不能写死歌单ID。如此设计,服务就显得很灵活。

二、歌单

抓取歌单的目的,是使用歌单。所以还需要提供查询方法查询歌单。方法的参数也是歌单ID。

  public Artist getArtist(String artistId);
三、歌曲

歌单中有很多歌曲,还可以提供一个查询歌曲的方法。虽然歌单模型中已经包含歌曲了,但是提供一个通用简单易用的方法也是有意义的。方法参数是 歌单ID和歌曲ID。

  public Song getSong(String artistId, String songId);

设计

在确定了需要的方法后,就可以完成模型图了:

实现类的 artists 变量,用来 存储 所有爬下来的歌单,以主键 id 作为 key。对 Map 不熟悉的同学,可以复习一下《Java 基础强化》课程

当然,程序停止运行后,这些爬下来的数据就都没有了。

扩展知识

对于服务实现,往往需要一个初始化实例变量的方法,这样做扩展性比较好。请看代码:

  private Map<String, Artist> artists;private void init() {artists = new HashMap<>();}

爬取歌单服务实现步骤一

服务实现

回顾服务详细设计图:

start() 方法实现的整体思路

SongCrawlerServiceImpl 类的 start() 方法的作用是执行歌单的抓取。

但歌单爬取比较复杂,下列思路供大家参考:

  • 在 SongCrawlerServiceImpl 类的 start() 方法中按顺序实现这些步骤,就实现了歌单的抓取操作;
  • 封装为一个私有方法,是因为包含了一系列子步骤;
    • 因为这些方法不需要暴露给其他类调用的,所以用 private 修饰符;
    • 封装私有方法的核心目的,还是理顺思路,抽象出一个相对完整的步骤;
  • 私有方法名及参数需要大家动脑筋想一想。

提示

start() 方法爬取数据过程中,可能用到私有方法封装、Map 操作等,提供以下示例代码供参考:

@Override
public void start(String artistId) {// 参数判断,未输入参数则直接返回if (artistId == null || artistId.equals("")) {return;  }// 执行初始化init();// 取得整体数据对象Map returnData = getArtistObj(artistId);// 构建填充了属性的 Artist 实例Artist artist = buildArtist(returnData);
}

举例:

package com.youkeda.music.service.impl;
import com.alibaba.fastjson.JSON;
import com.youkeda.music.model.Artist;
import com.youkeda.music.model.Song;
import com.youkeda.music.service.SongCrawlerService;
import java.util.HashMap;
import java.util.Map;/*** 音乐抓取服务的实现*/
public class SongCrawlerServiceImpl implements SongCrawlerService {// 歌单数据仓库private Map<String, Artist> artists;private void init() {artists = new HashMap<>();}@Overridepublic void start(String artistId) {if (artistId==null||artistId.equals("")){return;}init();}@Overridepublic Artist getArtist(String artistId) {return null;}@Overridepublic Song getSong(String artistId, String songId) {return null;}private Map getArtistObj(String artistId){String aUrl="http://neteaseapi.youkeda.com:3000/artists?id="+artistId;String content=getPageContentSync(aUrl);Map returnData= JSON.parseObject(content,Map.class);return returnData;}private String getPageContentSync(String url) {String result =null;return result;}
}

爬取歌单服务实现步骤二

构建填充了属性的 Artist 实例

所谓填充了属性的实例,其实就是先创建实例:

Dog dog = new Dog();

然后调用其 setter 方法给属性赋值。这样一个对象才创建完整。

dog.setName("斗斗");
dog.setBreed("bulldog");

一般来说,只要说到“对象实例化”,肯定是要给其属性赋值的。

package com.youkeda.music.service.impl;import com.alibaba.fastjson.JSON;
import com.youkeda.music.model.Artist;
import com.youkeda.music.model.Song;
import com.youkeda.music.service.SongCrawlerService;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import okhttp3.Call;
import okhttp3.OkHttpClient;
import okhttp3.Request;/*** 音乐抓取服务的实现*/
public class SongCrawlerServiceImpl implements SongCrawlerService {private static final String ARTIEST_API_PREFIX = "http://neteaseapi.youkeda.com:3000/artists?id=";// okHttpClient 实例private OkHttpClient okHttpClient;// 歌单数据仓库private Map<String, Artist> artists;private void init() {//1. 构建 okHttpClient 实例okHttpClient = new OkHttpClient();artists = new HashMap<>();}@Overridepublic void start(String artistId) {// 参数判断,未输入参数则直接返回if (artistId == null || artistId.equals("")) {return;}// 执行初始化init();// 取得整体数据对象。Map returnData = getArtistObj(artistId);Artist artist=buildArtist(returnData);}@Overridepublic Artist getArtist(String artistId) {return null;}@Overridepublic Song getSong(String artistId, String songId) {return null;}@SuppressWarnings("unchecked")private Map getArtistObj(String artistId) {// 构建歌单urlString aUrl = ARTIEST_API_PREFIX + artistId;// 调用 okhttp3 获取返回数据String content = getPageContentSync(aUrl);// 反序列化成 Map 对象Map returnData = JSON.parseObject(content, Map.class);return returnData;}private Artist buildArtist(Map returnData){Map artistData =(Map) returnData.get("artist");Artist artist=new Artist();artist.setId(artistData.get("id").toString());return artist;}/*** 根据输入的url,读取页面内容并返回*/private String getPageContentSync(String url) {//2.定义一个requestRequest request = new Request.Builder().url(url).build();//3.使用client去请求Call call = okHttpClient.newCall(request);String result = null;try {//4.获得返回结果result = call.execute().body().string();System.out.println("call " + url + " , content's size=" + result.length());} catch (IOException e) {System.out.println("request " + url + " error . ");e.printStackTrace();}return result;}
}

Test:

package com.youkeda.music.test;import com.youkeda.music.model.Artist;
import com.youkeda.music.model.Song;
import com.youkeda.music.service.SongCrawlerService;
import com.youkeda.music.service.impl.SongCrawlerServiceImpl;/*** 检查服务是否可以正确返回对象*/
public class SongCrawlerTest {private static final String SA_DING_DING = "萨顶顶";private static final String A_ID = "9270";private static final String ZUO_SHOU_ZHI_YUE = "左手指月";private static final String S_ID = "536096151";public static void main(String[] args) {SongCrawlerService songService = new SongCrawlerServiceImpl();songService.start(A_ID);System.out.println("Mission Complete");System.out.println("服务方法实现完毕,运行成功。非常棒!");System.out.println("请继续练习下一节。");}
}

爬取歌单服务实现步骤三

3.构建一组填充了属性的 Song 实例

package com.youkeda.music.service.impl;import com.alibaba.fastjson.JSON;
import com.youkeda.music.model.Artist;
import com.youkeda.music.model.Song;
import com.youkeda.music.service.SongCrawlerService;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import okhttp3.Call;
import okhttp3.OkHttpClient;
import okhttp3.Request;/*** 音乐抓取服务的实现*/
public class SongCrawlerServiceImpl implements SongCrawlerService {private static final String ARTIEST_API_PREFIX = "http://neteaseapi.youkeda.com:3000/artists?id=";// okHttpClient 实例private OkHttpClient okHttpClient;// 歌单数据仓库private Map<String, Artist> artists;private void init() {//1. 构建 okHttpClient 实例okHttpClient = new OkHttpClient();artists = new HashMap<>();}@Overridepublic void start(String artistId) {// 参数判断,未输入参数则直接返回if (artistId == null || artistId.equals("")) {return;}// 执行初始化init();// 取得整体数据对象。Map returnData = getArtistObj(artistId);// 构建填充了属性的 Artist 实例Artist artist = buildArtist(returnData);// 构建一组填充了属性的 Song 实例List<Song> songs = buildSongs(returnData);}@Overridepublic Artist getArtist(String artistId) {return null;}@Overridepublic Song getSong(String artistId, String songId) {return null;}@SuppressWarnings("unchecked")private Map getArtistObj(String artistId) {// 构建歌单urlString aUrl = ARTIEST_API_PREFIX + artistId;// 调用 okhttp3 获取返回数据String content = getPageContentSync(aUrl);// 反序列化成 Map 对象Map returnData = JSON.parseObject(content, Map.class);return returnData;}@SuppressWarnings("unchecked")private Artist buildArtist(Map returnData) {// 从 Map 对象中取得 歌单 数据。歌单也是一个子 Map 对象。Map artistData = (Map) returnData.get("artist");Artist artist = new Artist();artist.setId(artistData.get("id").toString());if (artistData.get("picUrl") != null) {artist.setPicUrl(artistData.get("picUrl").toString());}artist.setBriefDesc(artistData.get("briefDesc").toString());artist.setImg1v1Url(artistData.get("img1v1Url").toString());artist.setName(artistData.get("name").toString());artist.setAlias((List) artistData.get("alias"));return artist;}private List<Song> buildSongs(Map returnData) {// 从 Map 对象中取得一组 歌曲 数据List songsData = (List) returnData.get("hotSongs");List<Song> songs = new ArrayList<>();for (int i = 0; i < songsData.size(); i++) {Map songData = (Map) songsData.get(i);Song songObj = new Song();songObj.setId(songData.get("id").toString());songObj.setName(songData.get("name").toString());songs.add(songObj);}return songs;}/*** 根据输入的url,读取页面内容并返回*/private String getPageContentSync(String url) {//2.定义一个requestRequest request = new Request.Builder().url(url).build();//3.使用client去请求Call call = okHttpClient.newCall(request);String result = null;try {//4.获得返回结果result = call.execute().body().string();System.out.println("call " + url + " , content's size=" + result.length());} catch (IOException e) {System.out.println("request " + url + " error . ");e.printStackTrace();}return result;}
}

歌曲信息

歌曲详细信息

歌曲详情

项目要求:

  • 阅读 API ,学会如何调用 API
  • 分析及设计模型服务
  • 开发核心模型和服务的一般过程。

目标:

  • 获取每首歌的详细信息包含:音乐文件地址、所在专辑、所属歌手
  • 获取每首歌的热门评论和最新10条评论

歌曲相关的 API

1.获取每首歌的详细信息
信息描述信息说明
API地址http://neteaseapi.youkeda.com:3000/song/detail?ids=xxx,xxx
请求方式GET
参数说明
  • ids - 歌曲id,如果查询多个歌曲id之间用逗号隔开
测试例子http://neteaseapi.youkeda.com:3000/song/detail?ids=347230,347231
2.获取每首歌的评论
信息描述信息说明
API地址http://neteaseapi.youkeda.com:3000/comment/music?id=xxx&limit=xxx
请求方式GET
参数说明

id - 歌曲id

limit - 评论条数

测试例子http://neteaseapi.youkeda.com:3000/comment/music?id=186016&limit=1

注意:如果看不懂 URL 中 ?=& 等符号的含义,请复习《Java网络编程》。

3.获取每首歌的音乐文件地址
信息描述信息说明
API地址http://neteaseapi.youkeda.com:3000/song/url?id=xxx,xxx
请求方式GET
参数说明

id - 歌曲id,如果查询多个歌曲id之间用逗号隔开,注意不是ids

测试例子http://neteaseapi.youkeda.com:3000/song/url?id=405998841,33894312

歌曲详情API和歌曲音乐文件地址API,支持多个歌曲id用逗号隔开,就意味着支持批量查询。批量查询可以有效减少网络传输次数,提升性能和效率。

模型设计

歌单的模型设计和服务已经完成,歌曲模型只是简单的设计了两个属性:id 和 name 。模型的设计是跟具体场景和需求有关。

本章的重点是歌曲,需要获取每首歌的详情、评论、音乐文件地址,那么原来的歌曲模型就不能满足需求了,需要对歌曲模型进行扩展

在这个设计中,与以往略有一点不同的是,评论者其实就是某位用户,所以评论者的类型是 User 。用户模型其实是非常通用的,因为一般来说用户可以做很多事情,跟很多模型都有关。

但是对于评论模型来说,为了准确表达用户在评论中的作用,实例变量的名称叫作 commentUser ,也就是说,变量名不一定要与其类型完全相同,根据场景,可以不一致,能清晰表达含义即可。

请看代码:

public class Comment {private User commentUser;public User getCommentUser() {return commentUser;}public void setCommentUser(User commentUser) {this.commentUser = commentUser;}}

服务设计

继完成歌曲模型的扩展后,本节就要完成歌曲服务相关接口和方法的设计了。

实际上,在抓取歌单的时候,已经抓取到歌曲相关的基础信息了。那么只需要在抓取过程中进行扩展,增加抓取歌曲详情、评论信息即可。

服务设计

  • 把原来 start() 方法里完成的抓取步骤,挪到 initArtistHotSongs() 中,作为歌曲和歌单初始化的方法。

  • 增加三个装配方法:

    • assembleSongDetail() 组装歌曲的详细信息
    • assembleSongComment() 组装歌曲的评论
    • assembleSongUrl() 组装歌曲的音乐文件地址
  • start() 方法的作为装配工,按顺序调用各个步骤:

  • start() 方法变化较大,相当于做了一次重构

这样做的目的,是基于把相对独立操作进行封装的思想,实现歌单抓取的过程中把步骤封装成私有方法也是基础这个思想。

这么做的优点让代码条理更清晰、更容易理解、更易于维护。

start() 方法重构过程的示例,请看代码演示:

  @Overridepublic void start(String artistId) {// 参数判断,未输入参数则直接返回if (artistId == null || artistId.equals("")) {return;}// 执行初始化init();// 初始化歌曲及歌单initArtistHotSongs(artistId);}private void initArtistHotSongs(String artistId) {// 取得整体数据对象。Map returnData = getArtistObj(artistId);// 构建填充了属性的 Artist 实例Artist artist = buildArtist(returnData);// 构建一组填充了属性的 Song 实例List<Song> songs = buildSongs(returnData);// 歌曲填入歌单artist.setSongList(songs);// 存入本地artists.put(artist.getId(), artist);}

扩展知识点

  1. 什么是重构?

    不改变代码接口的情况下,对代码作出修改,以改进程序的内部结构。本质上说,重构就是在代码写好之后改进它的设计

  2. 重构的目的?

    • 提高代码质量(性能、可读性、可重用性)
    • 修改Bug
    • 增加新功能

本次 start() 方法的重构,为了增加新的功能。
没有修改对外暴露的接口,只是修改了方法体内的实现。对其它调用者来说是透明的。

歌曲详情信息

服务实现

模型和服务方法确定后,就到了实现的环节了。

在编码之前,大家可以先回忆一下爬取歌单的实现步骤。实际上无论是爬取歌单,还是将要爬取的歌曲信息、评论信息整体的过程是大体相似的:

回忆一下歌曲详细信息的 API :

信息描述信息说明
API地址http://neteaseapi.youkeda.com:3000/song/detail?ids=xxx,xxx
请求方式GET
参数说明

ids - 歌曲id,如果查询多个歌曲id之间用逗号隔开

测试例子http://neteaseapi.youkeda.com:3000/song/detail?ids=347230,347231

与歌单 API 不同的是,歌曲详情 API 中 ids 的值要复杂一些。

歌曲ID已经保存在歌单数据仓库 artists 中,那么就多了一个步骤:组装 ids 的值。

为了组装 ids 的值,需要对歌单进行遍历操作,把歌曲ID一个个找出来。

多个id值用逗号隔开

大家可以用字符串相加(+)的方式,把歌曲 id 与逗号拼接成一个字符串。

下面演示一种更加优雅的方式:

private void assembleSongDetail(String artistId) {Artist artist = getArtist(artistId);// 收集一个歌单中所有歌曲的id,放入一个listList<String> songIds = new ArrayList<>();List<Song> songs = artist.getSongList();for (Song song : songs) {songIds.add(song.getId());  }// 一个歌单中所有歌曲的id,组装成用逗号分割的字符串,形如:347230,347231。记住这个用法,很方便String sIdsParam = String.join(",", songIds);}

方法重构

本节开头的过程分析中,识别了与API无关的通用步骤

实际上我们已经写了 private Map getArtistObj(String artistId) 方法实现了这个步骤。但这个方法需要重构,使爬取歌曲详情时也可以复用,必须避免重复代码

下面演示如何重构(重构前的代码注释掉了,大家可以比较一下前后差异):

//private Map getArtistObj(String artistId) {private Map getSourceDataObj(String prefix, String postfix) {// 构建歌单url//String aUrl = ARTIEST_API_PREFIX + artistId;String aUrl = prefix + postfix;return null;//这一行仅演示,不要照抄哦}private void initArtistHotSongs(String artistId) {// 取得整体数据对象。//Map returnData = getArtistObj(artistId);Map returnData = getSourceDataObj(ARTIEST_API_PREFIX, artistId);}

(仅演示,为了效果,删除了一批无关的代码)

私有方法重构以后,要检查所有的调用代码,避免编译错误。

大家可以多看几遍,记住动态演示的修改点。

小技巧:巧用 Map

调用歌曲详情 API 返回的结果中,歌曲存放在一个集合中,使用起来不方便。可以把源歌曲详情对象放在一个 Map 中,可以很方便的读取。

请看下面演示:

private void assembleSongDetail(String artistId) {Artist artist = getArtist(artistId);List<String> songIds = new ArrayList<>();List<Song> songs = artist.getSongList();for (Song song : songs) {songIds.add(song.getId());}String sIdsParam = String.join(",", songIds);// 抓取结果Map songsDetailObj = getSourceDataObj(S_D_API_PREFIX, sIdsParam);// 原始数据中的 songs 是歌曲列表List<Map> sourceSongs = (List<Map>) songsDetailObj.get("songs");// 临时的 MapMap<String, Map> sourceSongsMap = new HashMap<>();// 遍历歌曲列表for (Map songSourceData : sourceSongs) {String sId = songSourceData.get("id").toString();// 原始歌曲数据对象放入一个临时的 Map 中sourceSongsMap.put(sId, songSourceData);}// 再次遍历歌单中的歌曲,填入详情数据for (Song song : songs) {String sId = song.getId();// 从临时的Map中取得对应的歌曲源数据,使用id直接获取,比较方便Map songSourceData = sourceSongsMap.get(sId);}}

package com.youkeda.music.service.impl;import com.alibaba.fastjson.JSON;
import com.youkeda.music.model.Album;
import com.youkeda.music.model.Artist;
import com.youkeda.music.model.Song;
import com.youkeda.music.model.User;
import com.youkeda.music.service.SongCrawlerService;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import okhttp3.Call;
import okhttp3.OkHttpClient;
import okhttp3.Request;/*** 音乐抓取服务的实现*/
public class SongCrawlerServiceImpl implements SongCrawlerService {// 歌单 APIprivate static final String ARTIEST_API_PREFIX = "http://neteaseapi.youkeda.com:3000/artists?id=";// 歌曲详情 APIprivate static final String S_D_API_PREFIX = "http://neteaseapi.youkeda.com:3000/song/detail?ids=";// okHttpClient 实例private OkHttpClient okHttpClient;// 歌单数据仓库private Map<String, Artist> artists;private void init() {//1. 构建 okHttpClient 实例okHttpClient = new OkHttpClient();artists = new HashMap<>();}@Overridepublic void start(String artistId) {// 参数判断,未输入参数则直接返回if (artistId == null || artistId.equals("")) {return;}// 执行初始化init();// 初始化歌曲及歌单initArtistHotSongs(artistId);assembleSongDetail(artistId);assembleSongComment(artistId);assembleSongUrl(artistId);}@Overridepublic Artist getArtist(String artistId) {return artists.get(artistId);}@Overridepublic Song getSong(String artistId, String songId) {Artist artist = artists.get(artistId);List<Song> songs = artist.getSongList();if (songs == null) {return null;}for (Song song : songs) {if (song.getId().equals(songId)) {return song;}}return null;}private Map getSourceDataObj(String prefix, String postfix) {// 构建歌单urlString aUrl = prefix + postfix;// 调用 okhttp3 获取返回数据String content = getPageContentSync(aUrl);// 反序列化成 Map 对象Map returnData = JSON.parseObject(content, Map.class);return returnData;}private Artist buildArtist(Map returnData) {// 从 Map 对象中取得 歌单 数据。歌单也是一个子 Map 对象。Map artistData = (Map) returnData.get("artist");Artist artist = new Artist();artist.setId(artistData.get("id").toString());if (artistData.get("picUrl") != null) {artist.setPicUrl(artistData.get("picUrl").toString());}artist.setBriefDesc(artistData.get("briefDesc").toString());artist.setImg1v1Url(artistData.get("img1v1Url").toString());artist.setName(artistData.get("name").toString());artist.setAlias((List) artistData.get("alias"));return artist;}private List<Song> buildSongs(Map returnData) {// 从 Map 对象中取得一组 歌曲 数据List songsData = (List) returnData.get("hotSongs");List<Song> songs = new ArrayList<>();for (int i = 0; i < songsData.size(); i++) {Map songData = (Map) songsData.get(i);Song songObj = new Song();songObj.setId(songData.get("id").toString());songObj.setName(songData.get("name").toString());songs.add(songObj);}return songs;}/*** 根据输入的url,读取页面内容并返回*/private String getPageContentSync(String url) {//2.定义一个requestRequest request = new Request.Builder().url(url).build();//3.使用client去请求Call call = okHttpClient.newCall(request);String result = null;try {//4.获得返回结果result = call.execute().body().string();System.out.println("call " + url + " , content's size=" + result.length());} catch (IOException e) {System.out.println("request " + url + " error . ");e.printStackTrace();}return result;}private void initArtistHotSongs(String artistId) {// 取得整体数据对象。Map returnData = getSourceDataObj(ARTIEST_API_PREFIX, artistId);// 构建填充了属性的 Artist 实例Artist artist = buildArtist(returnData);// 构建一组填充了属性的 Song 实例List<Song> songs = buildSongs(returnData);// 歌曲填入歌单artist.setSongList(songs);// 存入本地artists.put(artist.getId(), artist);}@SuppressWarnings("unchecked")private void assembleSongDetail(String artistId) {Artist artist = getArtist(artistId);// 取不到歌单说明参数输入错误if (artist == null) {return;}// 收集一个歌单中所有歌曲的id,放入一个listList<String> songIds = new ArrayList<>();List<Song> songs = artist.getSongList();for (Song song : songs) {songIds.add(song.getId());}// 一个歌单中所有歌曲的id,组装成用逗号分割的字符串,形如:347230,347231。记住这个用法,很方便String sIdsParam = String.join(",", songIds);// 抓取结果Map songsDetailObj = getSourceDataObj(S_D_API_PREFIX, sIdsParam);// 原始数据中的 songs 是歌曲列表List<Map> sourceSongs = (List<Map>) songsDetailObj.get("songs");// 临时的 MapMap<String, Map> sourceSongsMap = new HashMap<>();// 遍历歌曲列表for (Map songSourceData : sourceSongs) {String sId = songSourceData.get("id").toString();// 原始歌曲数据对象放入一个临时的 Map 中sourceSongsMap.put(sId, songSourceData);}// 再次遍历歌单中的歌曲,填入详情数据for (Song song : songs) {String sId = song.getId();// 从临时的Map中取得对应的歌曲源数据,使用id直接获取,比较方便Map songSourceData = sourceSongsMap.get(sId);// 源歌曲数据中,ar 字段是歌手列表List<Map> singersData = (List<Map>) songSourceData.get("ar");// 歌手集合List<User> singers = new ArrayList<>();for (Map singerData : singersData) {// 歌手对象User singer = new User();singer.setId(singerData.get("id").toString());singer.setNickName(singerData.get("name").toString());// 歌手集合放入歌手对象singers.add(singer);}// 歌手集合放入歌曲song.setSingers(singers);// 专辑Map albumData = (Map) songSourceData.get("al");Album album = new Album();album.setId(albumData.get("id").toString());album.setName(albumData.get("name").toString());if (albumData.get("picUrl") != null) {album.setPicUrl(albumData.get("picUrl").toString());}// 专辑对象放入歌曲song.setAlbum(album);}}private void assembleSongComment(String artistId) {}private void assembleSongUrl(String artistId) {}
}

歌曲评论及音乐文件

服务实现

如果对整体流程不熟悉,再复习流程图和每节任务代码。

API 是否支持批量参数的区别

必须注意的是,歌曲详情API歌曲文件API 都是支持批量查询(id号用逗号分割的形式)的,而 歌曲评论API 不支持批量查询,这就导致抓取代码实现有所不同。

  • 如果 API 支持批量查询,就要选调用API获取批量数据,再对歌曲进行循环遍历,解析出需要的源数据。

批量查询的目的是 减少远程网络请求次数提高效率 。

  • 如果 API 不支持批量查询,就只能先对歌曲进行循环遍历,每次遍历都调用一次 API ,再解析数据。

这么做效率相对较低。

所以,切勿每次遍历都调用一次支持批量查询的 API 。

下面演示 歌曲评论API 的过程中有什么不同:

private static final String S_C_API_PREFIX = "http://neteaseapi.youkeda.com:3000/comment/music?id=";private void assembleSongComment(String artistId) {Artist artist = getArtist(artistId);List<Song> songs = artist.getSongList();for (Song song : songs) {String sIdsParam = song.getId() + "&limit=5";// 抓取结果Map songsCommentObj = getSourceDataObj(S_C_API_PREFIX, sIdsParam);}}

具体的 评论API 地址及说明,请随时查阅 3.1 哦。

方法重构

歌曲详情API歌曲文件API 都支持批量查询,对应的 assembleSongDetail() 方法 、 assembleSongUrl() 方法,都需要组装 xxxx,xxxx 格式的参数。

先实现了 assembleSongDetail() 方法,再实现 assembleSongUrl() 方法时,就需要对组装批量参数的代码进行重构。

下面演示重构过程: 具体变化可对比之前的作业

private void assembleSongDetail(String artistId) {Artist artist = getArtist(artistId);// 删除其它语句,保留必要的语句List<Song> songs = artist.getSongList();String sIdsParam = buildManyIdParam(songs);// 抓取结果Map songsDetailObj = getSourceDataObj(S_D_API_PREFIX, sIdsParam);}private void assembleSongUrl(String artistId) {Artist artist = getArtist(artistId);// 删除其它语句,保留必要的语句List<Song> songs = artist.getSongList();String sIdsParam = buildManyIdParam(songs);// 抓取结果Map songsFileObj = getSourceDataObj(S_F_API_PREFIX, sIdsParam);}private String buildManyIdParam(List<Song> songs) {// 收集一个歌单中所有歌曲的id,放入一个listList<String> songIds = new ArrayList<>();for (Song song : songs) {songIds.add(song.getId());  }// 一个歌单中所有歌曲的id,组装成用逗号分割的字符串,形如:347230,347231。记住这个用法,很方便String sIdsParam = String.join(",", songIds);return sIdsParam;}

完成

开发完毕 assembleSongComment() 和 assembleSongUrl() 方法,对于参数指定歌单中的所有歌曲,补充完评论、热门评论、音乐文件地址数据。

重构要求

1.重构组装批量参数的过程
  • 定义新的 buildManyIdParam() 方法,完成组装批量参数的过程,返回结果字符串。这个过程本节已经演示过,建议理解后自己敲代码,不要拷贝。

    private String buildManyIdParam(List<Song> songs)
    
  • 别忘了修改 assembleSongDetail() 中的相关代码,改为调用新方法

2.避免使用重复代码解析评论源数据

热门评论的字段是 hotComments,评论的字段是 comments 。可以 自定义一个方法 完成热门评论和评论源数据的解析。

小提示:

  • 方法参数是评论源数据列表List<Map>,返回值是 List<Comment>
  • 此方法不演示了,请大家动脑筋思考。
  • 也可以应用重构的思想,先写出来解析热门评论的过程,再重构方法。

约定

调用评论 API 时,limit 参数设置为 5

温馨提醒

  • 多在浏览器中观察本章第一节 API测试例子 的返回值,理解数据的结构
  • 可开发前可以琢磨琢磨,理清思路。其实不复杂,前面完成了任务的话,应该熟练了。
package com.youkeda.music.service.impl;import com.alibaba.fastjson.JSON;
import com.youkeda.music.model.Album;
import com.youkeda.music.model.Artist;
import com.youkeda.music.model.Comment;
import com.youkeda.music.model.Song;
import com.youkeda.music.model.User;
import com.youkeda.music.service.SongCrawlerService;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import okhttp3.Call;
import okhttp3.OkHttpClient;
import okhttp3.Request;/*** 音乐抓取服务的实现*/
public class SongCrawlerServiceImpl implements SongCrawlerService {// 歌单 APIprivate static final String ARTIEST_API_PREFIX = "http://neteaseapi.youkeda.com:3000/artists?id=";// 歌曲详情 APIprivate static final String S_D_API_PREFIX = "http://neteaseapi.youkeda.com:3000/song/detail?ids=";// 歌曲评论 APIprivate static final String S_C_API_PREFIX = "http://neteaseapi.youkeda.com:3000/comment/music?id=";// 歌曲音乐文件 APIprivate static final String S_F_API_PREFIX = "http://neteaseapi.youkeda.com:3000/song/url?id=";// okHttpClient 实例private OkHttpClient okHttpClient;// 歌单数据仓库private Map<String, Artist> artists;private void init() {//1. 构建 okHttpClient 实例okHttpClient = new OkHttpClient();artists = new HashMap<>();}@Overridepublic void start(String artistId) {// 参数判断,未输入参数则直接返回if (artistId == null || artistId.equals("")) {return;}// 执行初始化init();// 初始化歌曲及歌单initArtistHotSongs(artistId);assembleSongDetail(artistId);assembleSongComment(artistId);assembleSongUrl(artistId);}@Overridepublic Artist getArtist(String artistId) {return artists.get(artistId);}@Overridepublic Song getSong(String artistId, String songId) {Artist artist = artists.get(artistId);List<Song> songs = artist.getSongList();if (songs == null) {return null;}for (Song song : songs) {if (song.getId().equals(songId)) {return song;}}return null;}@SuppressWarnings("unchecked")private Map getSourceDataObj(String prefix, String postfix) {// 构建歌单urlString aUrl = prefix + postfix;// 调用 okhttp3 获取返回数据String content = getPageContentSync(aUrl);// 反序列化成 Map 对象Map returnData = JSON.parseObject(content, Map.class);return returnData;}@SuppressWarnings("unchecked")private Artist buildArtist(Map returnData) {// 从 Map 对象中取得 歌单 数据。歌单也是一个子 Map 对象。Map artistData = (Map) returnData.get("artist");Artist artist = new Artist();artist.setId(artistData.get("id").toString());if (artistData.get("picUrl") != null) {artist.setPicUrl(artistData.get("picUrl").toString());}artist.setBriefDesc(artistData.get("briefDesc").toString());artist.setImg1v1Url(artistData.get("img1v1Url").toString());artist.setName(artistData.get("name").toString());artist.setAlias((List) artistData.get("alias"));return artist;}private List<Song> buildSongs(Map returnData) {// 从 Map 对象中取得一组 歌曲 数据List songsData = (List) returnData.get("hotSongs");List<Song> songs = new ArrayList<>();for (int i = 0; i < songsData.size(); i++) {Map songData = (Map) songsData.get(i);Song songObj = new Song();songObj.setId(songData.get("id").toString());songObj.setName(songData.get("name").toString());songs.add(songObj);}return songs;}/*** 根据输入的url,读取页面内容并返回*/private String getPageContentSync(String url) {//2.定义一个requestRequest request = new Request.Builder().url(url).build();//3.使用client去请求Call call = okHttpClient.newCall(request);String result = null;try {//4.获得返回结果result = call.execute().body().string();System.out.println("call " + url + " , content's size=" + result.length());} catch (IOException e) {System.out.println("request " + url + " error . ");e.printStackTrace();}return result;}private void initArtistHotSongs(String artistId) {// 取得整体数据对象。Map returnData = getSourceDataObj(ARTIEST_API_PREFIX, artistId);// 构建填充了属性的 Artist 实例Artist artist = buildArtist(returnData);// 构建一组填充了属性的 Song 实例List<Song> songs = buildSongs(returnData);// 歌曲填入歌单artist.setSongList(songs);// 存入本地artists.put(artist.getId(), artist);}@SuppressWarnings("unchecked")private void assembleSongDetail(String artistId) {Artist artist = getArtist(artistId);// 取不到歌单说明参数输入错误if (artist == null) {return;}// 删除其它语句,保留必要的语句List<Song> songs = artist.getSongList();String sIdsParam = buildManyIdParam(songs);// 抓取结果Map songsDetailObj = getSourceDataObj(S_D_API_PREFIX, sIdsParam);// 原始数据中的 songs 是歌曲列表List<Map> sourceSongs = (List<Map>) songsDetailObj.get("songs");// 临时的 MapMap<String, Map> sourceSongsMap = new HashMap<>();// 遍历歌曲列表for (Map songSourceData : sourceSongs) {String sId = songSourceData.get("id").toString();// 原始歌曲数据对象放入一个临时的 Map 中sourceSongsMap.put(sId, songSourceData);}// 再次遍历歌单中的歌曲,填入详情数据for (Song song : songs) {String sId = song.getId();// 从临时的Map中取得对应的歌曲源数据,使用id直接获取,比较方便Map songSourceData = sourceSongsMap.get(sId);// 源歌曲数据中,ar 字段是歌手列表List<Map> singersData = (List<Map>) songSourceData.get("ar");// 歌手集合List<User> singers = new ArrayList<>();for (Map singerData : singersData) {// 歌手对象User singer = new User();singer.setId(singerData.get("id").toString());singer.setNickName(singerData.get("name").toString());// 歌手集合放入歌手对象singers.add(singer);}// 歌手集合放入歌曲song.setSingers(singers);// 专辑Map albumData = (Map) songSourceData.get("al");Album album = new Album();album.setId(albumData.get("id").toString());album.setName(albumData.get("name").toString());if (albumData.get("picUrl") != null) {album.setPicUrl(albumData.get("picUrl").toString());}// 专辑对象放入歌曲song.setAlbum(album);}}@SuppressWarnings("unchecked")private void assembleSongComment(String artistId) {Artist artist = getArtist(artistId);// 取不到歌单说明参数输入错误if (artist == null) {return;}List<Song> songs = artist.getSongList();for (Song song : songs) {String sIdsParam = song.getId() + "&limit=5";// 抓取结果Map songsCommentObj = getSourceDataObj(S_C_API_PREFIX, sIdsParam);// 热门评论列表List<Map> hotCommentsObj = (List<Map>) songsCommentObj.get("hotComments");// 热门评论列表List<Map> commontsObj = (List<Map>) songsCommentObj.get("comments");song.setHotComments(buildComments(hotCommentsObj));song.setComments(buildComments(commontsObj));}}@SuppressWarnings("unchecked")private void assembleSongUrl(String artistId) {Artist artist = getArtist(artistId);// 取不到歌单说明参数输入错误if (artist == null) {return;}// 删除其它语句,保留必要的语句List<Song> songs = artist.getSongList();String sIdsParam = buildManyIdParam(songs);// 抓取结果Map songsFileObj = getSourceDataObj(S_F_API_PREFIX, sIdsParam);// 原始数据中的 data 是音乐文件列表List<Map> datas = (List<Map>) songsFileObj.get("data");// 临时的 MapMap<String, Map> sourceSongsMap = new HashMap<>();// 遍历音乐文件列表for (Map songFileData : datas) {String sId = songFileData.get("id").toString();// 原始音乐文件数据对象放入一个临时的 Map 中sourceSongsMap.put(sId, songFileData);}// 再次遍历歌单中的歌曲,填入音乐文件URLfor (Song song : songs) {String sId = song.getId();// 从临时的Map中取得对应的音乐文件源数据,使用id直接获取,比较方便Map songFileData = sourceSongsMap.get(sId);// 源音乐文件数据中,url 字段就是文件地址if (songFileData != null && songFileData.get("url") != null) {String songFileUrl = songFileData.get("url").toString();song.setSourceUrl(songFileUrl);}}}private List<Comment> buildComments(List<Map> commontsObj) {List<Comment> comments = new ArrayList<>();for (Map sourceComment : commontsObj) {Comment commont = new Comment();commont.setContent(sourceComment.get("content").toString());commont.setId(sourceComment.get("commentId").toString());commont.setLikedCount(sourceComment.get("likedCount").toString());commont.setTime(sourceComment.get("time").toString());User user = new User();Map sourceUserData = (Map)sourceComment.get("user");user.setId(sourceUserData.get("userId").toString());user.setNickName(sourceUserData.get("nickname").toString());user.setAvatar(sourceUserData.get("avatarUrl").toString());commont.setCommentUser(user);comments.add(commont);}return comments;}private String buildManyIdParam(List<Song> songs) {// 收集一个歌单中所有歌曲的id,放入一个listList<String> songIds = new ArrayList<>();for (Song song : songs) {songIds.add(song.getId());}// 一个歌单中所有歌曲的id,组装成用逗号分割的字符串,形如:347230,347231。记住这个用法,很方便String sIdsParam = String.join(",", songIds);return sIdsParam;}}

制作图云

图云

完成了歌单信息的抓取之后,都是为了最后一章做准备:制作图云。

zhoujielun.png

类似这种由关键词组成的图云,就是词云。词云的呈现形式是图片,在视觉上突出了关键词,效果非常好。

而关键词的来源,就是歌单中,每首歌的评论。

那么具体怎么做呢?

  1. 对评论进行分词。例如:
    • 老子要听一辈子周杰伦 分词结果: 老子 /  /  / 一辈子 / 周杰伦
    • 半夜听着周董的老歌,看着大家的评论,满满的回忆 分词结果:半夜 / 听着 / 周董 /  / 老歌 / 看着 / 大家 /  / 评论 / 满满的 / 回忆
  2. 把分词以后的关键词出现次数进行统计,排序
  3. 最后根据关键词的频率进行视觉显示

上述都是理论思路,但每一步其实都是很难的,涉及到非常多复杂技术,比如:词性标注,自然语言分析,歧义词分析,图片绘制,图文智能排版等。

需要大家都很强的数学数据结构算法功底,再用上几周、甚至几个月的时间才能理解。

那么,有没有更加简便和快速的办法实现词云呢?

答案是有的,使用别人写好的工具。

Kumo

在 Java 中,最常用的词云库为 Kumo 。github地址为:

https://github.com/kennycason/kumo

使用方式也比较简单,首先需要在 pom.xml 文件中引入相关的依赖。

<dependency><groupId>com.kennycason</groupId><artifactId>kumo-core</artifactId><version>1.17</version>
</dependency>
<!-- 下面tokenizers是为了中文分词引入 -->
<dependency><groupId>com.kennycason</groupId><artifactId>kumo-tokenizers</artifactId><version>1.17</version>
</dependency>

每个语言的分词词性和分词算法是不同的,所以需要单独引入中文分词:kumo-tokenizers 。

服务设计

  • 在 SongCrawlerServiceImpl 中增加一个方法 private void generateWordCloud(String artistId) ,此方法被对外暴露的接口方法 start() 在最后一个步骤调用,爬取信息后接着制作词云。

使用 Kumo

Kumo 的使用,学习起来也是要理解很多概念。但是已经封装好了一个工具类:WordCloudUtil 。 generateWordCloud() 会调用到 WordCloudUtil 。

下列代码演示了如何调用 WordCloudUtil :

@Overridepublic void start(String artistId) {generateWordCloud(artistId);}private void generateWordCloud(String artistId) {Artist artist = getArtist(artistId);List<Song> songs = artist.getSongList();List<String> contents = new ArrayList<>();for (Song song : songs) {// 遍历歌曲所有的评论,包括普通评论和热门评论,把评论内容字符串存入 contents 集合  }// 调用方法,制作词云WordCloudUtil.generate(artistId, contents);
}

WordCloudUtil 的具体代码,大家可以参照注释进行研究。但不是本课程的重点,主要学会调用即可。

任务

参照流程图:

完成:

  • 在 SongCrawlerServiceImpl 中增加一个方法 private void generateWordCloud(String artistId) ,并且实现调用逻辑,完成词云制作。
  • start() 在最后一个步骤调用 generateWordCloud()
  • 注意避免重复代码

执行完毕以后,在工程目录下找一找,有没有名称形如“wordCloud-XXX.png”的图片文件。文件存在表示执行成功,点击图片即可查看效果哦。


http://www.mrgr.cn/news/71839.html

相关文章:

  • js: 区分后端返回数字是否为null、‘-’ 或正常number类型数字。
  • LabVIEW滤波器功能
  • Linux---shell脚本练习
  • RK3399开发板Linux实时性改造
  • 【update 更新数据语法合集】.NET开源ORM框架 SqlSugar 系列
  • zerox - 使用视觉模型将 PDF 转换为 Markdown
  • 论文解读:CARAT
  • cache(五)Write-through,Write-back,Write-allocate,No-write-allocate
  • 【t365】基于springboot的高校疫情防控系统
  • uniapp路由与页面跳转详解:API调用与Navigator组件实战
  • linux性能提升之sendmmsg和recvmmsg
  • kafka夺命连环三十问(16-22)
  • A/B测试的误区与优化策略:如何最大化客户留存ROI?
  • 【LeetCode】【算法】136. 只出现一次的数字
  • 数据结构《链表》
  • ML 系列: 第 23 节 — 离散概率分布 (多项式分布)
  • 【MySQL 保姆级教学】事务的自动提交和手动提交(重点)--上(13)
  • 移动电源测试中最核心的测试项目有哪些?-纳米软件
  • 多线程和线程同步复习
  • 鸿蒙next版开发:ArkTS组件通用属性(Flex布局)
  • python语言基础-4 常用模块-4.7 pyinstaller模块
  • Spring生态学习路径与源码深度探讨
  • 今天出了10个4声母 .com
  • 1163:阿克曼(Ackmann)函数
  • 词汇积累之倒行逆施、上行下效极简理解
  • 百度富文本禁止编辑