2019独角兽企业重金招聘Python工程师标准>>>

利用FFMPEG实现一个音频转码服务

提供一个音频转码服务,主要是利用ffmpeg实现转码,利用java web对外提供http服务接口

背景

音频转码服务算是比较基础的了,之前一直没做,最近有个需求背景,是将微信的amr格式音频,转换为mp3格式,否则h5页面的音频将无法播放

出于这个转码的场景,顺带着搭建一个多媒体处理服务应用(目标是图片的基本操作,音频、视频的常用操作等)

拟采用的技术

  1. 图片
  • imageMagic/graphicMagic + im4java
  1. 音频
  • ffmpeg + Runtime.getRuntime().exec(cmd);
  1. Spring Boot + Spring Mvc 提供http服务接口

本篇重点

使用ffmpeg提供音频转码的服务接口

准备

1. ffmpeg 安装

安装脚本如下

#!/bin/bash## download ffmpge cmd
wget https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-64bit-static.tar.xz## exact package
xz -d ffmpeg-release-64bit-static.tar.xz
tar -xvf ffmpeg-release-64bit-static.tar
mv ffmpeg-release-64bit-static ffmpeg
cd ffmpeg

测试

进入下载的目录,内部有一个 ffmpeg 的可执行文件,主要利用它来实现音频转码

./ffmpeg -version 查看ffmpeg的版本

转码测试

先准备一个测试文件 test.amr (不要直接从微信的文件夹中获取语音文件,微信做过处理,非标准的amr文件,如果手头没有,可以使用这个测试 amrTestAudio.amr )

转码命令

./ffmpeg -i test.amr test.mp3

然后可以看到新增一个mp3文件,然后用播放器,打开确认是否有问题

2. 工程搭建

使用Spring-Boot 搭建一个Web工程

直接用官网的创建方式即可,这里不做叙述

3. 编码实现

java利用命令行操作方式调用ffmpeg,实现音频转码,一个最简单的实现如下

// cmd 为待执行的命令行
String cmd = "ffmpeg -i src.amr test.mp3";
Process process = Runtime.getRuntime().exec(cmd);
process.waitFor();

就这样就可以了么? 显然并没有这么简陋,先谈谈直接这么用有什么问题

  • 扩展性,不好
  • 命令行的输出流,异常流没有处理
  • 对调用者而言不够友好
  • 上面只适用于本地音频转码,如果是对远程的音频,数据流格式的音频就不怎么方便了

出于以上几点,着手实现我们的目标,先看最后的测试case:

@Test
public void testAudioParse() {String[] arys = new String[]{"test.amr","/Users/yihui/GitHub/quick-media/common/src/test/resources/test.amr","http://s11.mogucdn.com/mlcdn/c45406/170713_3g25ec8fak8jch5349jd2dcafh61c.amr"};for (String src : arys) {try {String output = AudioWrapper.of(src).setOutputType("mp3").asFile();System.out.println(output);} catch (Exception e) {e.printStackTrace();}}
}

从使用的角度来看就很是简洁了,输出结果如下

/Users/yihui/GitHub/quick-media/common/target/test-classes/test_out.mp3
/Users/yihui/GitHub/quick-media/common/src/test/resources/test_out.mp3
/tmp/audio/170713_3g25ec8fak8jch5349jd2dcafh61c_out.mp3

实现

前面准备做好,测试的case也提前放出,那么可以看下如何实现了

配置类 AudioOptions

保存最终命令的配置相关信息,用于生成最终的执行命令行

对于音频转码,最终的cmd命令应该是: ffmpeg -i source.amr output.mp3,因此我们需要的参数有

  • 源文件 source.mar
  • 输出文件 output.mp3
  • 执行命令 ffmpeg
  • 可选参数 (ffmpeg带的一些参数)
public class AudioOptions {private String cmd = "ffmpeg -i ";private String src;private String dest;private Map<String, Object> options = new HashMap<>();public String getCmd() {return cmd;}public AudioOptions setCmd(String cmd) {this.cmd = cmd;return this;}public String getSrc() {return src;}public AudioOptions setSrc(String src) {this.src = src;return this;}public String getDest() {return dest;}public AudioOptions setDest(String dest) {this.dest = dest;return this;}public Map<String, Object> getOptions() {return options;}public AudioOptions addOption(String conf, Object value) {options.put("-" + conf, value);return this;}public String build() {StringBuilder builder = new StringBuilder(this.cmd);builder.append(" ").append(this.src);for (Map.Entry<String, Object> entry : options.entrySet()) {builder.append(entry.getKey().startsWith("-") ? " " : " -").append(entry.getKey()).append(" ").append(entry.getValue());}builder.append(" ").append(this.dest);return builder.toString();}
}

Audio处理封装类 AudioWrapper

对外暴露的接口,所有音频相关的操作都通过它来执行,正如上面的测试用例

  1. 对输入源,我们预留三种调用方式
  • 传入path路径(相对路径,绝对路径,网络路径)
  • URI 方式 (即传入网络链接方式,等同于上面的网络路径方式)
  • InputStream (文件输入流)
  1. 命令行调用,通常可选参数比较多,所以我们采用Builder模式来做参数的设置

  2. 源码如下

@Slf4j
public class AudioWrapper {public static Builder<String> of(String str) {Builder<String> builder = new Builder<>();return builder.setSource(str);}public static Builder<URI> of(URI uri) {Builder<URI> builder = new Builder<>();return builder.setSource(uri);}public static Builder<InputStream> of(InputStream inputStream) {Builder<InputStream> builder = new Builder<>();return builder.setSource(inputStream);}private static void checkNotNull(Object obj, String msg) {if (obj == null) {throw new IllegalStateException(msg);}}private static boolean run(String cmd) {try {return ProcessUtil.instance().process(cmd);} catch (Exception e) {log.error("operate audio error! cmd: {}, e: {}", cmd, e);return false;}}public static class Builder<T> {/*** 输入源*/private T source;/*** 源音频格式*/private String inputType;/*** 输出音频格式*/private String outputType;/*** 命令行参数*/private Map<String, Object> options = new HashMap<>();/*** 临时文件信息*/private FileUtil.FileInfo tempFileInfo;private String tempOutputFile;public Builder<T> setSource(T source) {this.source = source;return this;}public Builder<T> setInputType(String inputType) {this.inputType = inputType;return this;}public Builder<T> setOutputType(String outputType) {this.outputType = outputType;return this;}public Builder<T> addOption(String conf, Object val) {this.options.put(conf, val);return this;}private String builder() throws Exception {checkNotNull(source, "src file should not be null!");checkNotNull(outputType, "output Audio type should not be null!");tempFileInfo = FileUtil.saveFile(source, inputType);tempOutputFile = tempFileInfo.getPath() + "/" + tempFileInfo.getFilename() + "_out." + outputType;return new AudioOptions().setSrc(tempFileInfo.getAbsFile()).setDest(tempOutputFile).addOption("y", "") // 覆盖写.addOption("write_xing", 0) // 解决mac/ios 显示音频时间不对的问题.addOption("loglevel", "quiet") // 不输出日志.build();}public InputStream asStream() throws Exception {String output = asFile();if (output == null) {return null;}return new FileInputStream(new File(output));}public String asFile() throws Exception {String cmd = builder();return !run(cmd) ? null : tempOutputFile;}}}

上面的逻辑还是比较清晰的,但是有几个地方需要注意

  • 保存源文件到指定目录下 tempFileInfo = FileUtil.saveFile(source, inputType);
  • 执行命令的生成 :
    new AudioOptions().setSrc(tempFileInfo.getAbsFile()).setDest(tempOutputFile).addOption("y", "") // 覆盖写.addOption("write_xing", 0) // 解决mac/ios 显示音频时间不对的问题.addOption("loglevel", "quiet") // 不输出日志.build();
    
  • java执行cmd命令 private static boolean run(String cmd)

文件保存 FileUtil

这个工具类的目的比较清晰, 将源文件保存到指定的临时目录下,根据我们支持的三种方式,进行区分处理

我们定义一个数据结构 FileInfo 保存文件名相关信息

@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public static class FileInfo {/*** 文件所在的目录*/private String path;/*** 文件名 (不包含后缀)*/private String filename;/*** 文件类型*/private String fileType;public String getAbsFile() {return path  + "/" + filename + "." + fileType;}
}

根据输入,选择不同的实现方式保存,并返回文件信息

public static <T> FileInfo saveFile(T src, String inputType) throws Exception {if (src instanceof String) { // 给的文件路径,区分三中,本地绝对路径,相对路径,网络地址return saveFileByPath((String) src);} else if (src instanceof URI) { // 网络资源文件时,需要下载到本地临时目录下return saveFileByURI((URI) src);} else if (src instanceof InputStream) { // 输入流保存在到临时目录return saveFileByStream((InputStream) src, inputType);} else {throw new IllegalStateException("save file parameter only support String/URI/InputStream type! but input type is: " + (src == null ? null : src.getClass()));}}

1. 输入源为String时

三种路径的区分,对于http的格式,直接走URI输入源的方式

相对路径时,需要优先获取文件的绝对路径

/*** 根据path路径 生成源文件信息** @param path* @return* @throws Exception*/
private static FileInfo saveFileByPath(String path) throws Exception {if (path.startsWith("http")) {return saveFileByURI(URI.create(path));}String tmpAbsFile;if (path.startsWith("/")) { // 绝对路径tmpAbsFile = path;} else { // 相对路径转绝对路径tmpAbsFile = FileUtil.class.getClassLoader().getResource(path).getFile();}// 根据绝对路径,解析 目录 + 文件名  + 文件后缀return parseAbsFileToFileInfo(tmpAbsFile);
}/*** 根据绝对路径解析出 目录 + 文件名 + 文件后缀** @param absFile 全路径文件名* @return*/
public static FileInfo parseAbsFileToFileInfo(String absFile) {FileInfo fileInfo = new FileInfo();extraFilePath(absFile, fileInfo);extraFileName(fileInfo.getFilename(), fileInfo);return fileInfo;
}/*** 根据绝对路径解析 目录 + 文件名(带后缀)** @param absFilename* @param fileInfo*/
private static void extraFilePath(String absFilename, FileInfo fileInfo) {int index = absFilename.lastIndexOf("/");if (index < 0) {fileInfo.setPath(TEMP_PATH);fileInfo.setFilename(absFilename);} else {fileInfo.setPath(absFilename.substring(0, index));fileInfo.setFilename(index + 1 == absFilename.length() ? "" : absFilename.substring(index + 1));}
}/*** 根据带后缀文件名解析 文件名 + 后缀** @param fileName* @param fileInfo*/
private static void extraFileName(String fileName, FileInfo fileInfo) {int index = fileName.lastIndexOf(".");if (index < 0) {fileInfo.setFilename(fileName);fileInfo.setFileType("");} else {fileInfo.setFilename(fileName.substring(0, index));fileInfo.setFileType(index + 1 == fileName.length() ? "" : fileName.substring(index + 1));}
}

2. 输入源为URI时

网络资源,需要先把文件下载过来,所以就需要一个下载的工具类

一个非常初级的下载工具类: HttpUtil.java

@Slf4j
public class HttpUtil {public static InputStream downFile(String src) throws IOException {return downFile(URI.create(src));}/*** 从网络上下载文件** @param uri* @return* @throws IOException*/public static InputStream downFile(URI uri) throws IOException {HttpResponse httpResponse;try {Request request = Request.Get(uri);HttpHost httpHost = URIUtils.extractHost(uri);if (StringUtils.isNotEmpty(httpHost.getHostName())) {request.setHeader("Host", httpHost.getHostName());}request.addHeader("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36");httpResponse = request.execute().returnResponse();} catch (Exception e) {log.error("远程请求失败,url=" + uri, e);throw new FileNotFoundException();}int code = httpResponse.getStatusLine().getStatusCode();if (code != 200) {throw new FileNotFoundException();}return httpResponse.getEntity().getContent();}
}

具体的保存代码,比较简单,从网络上下载的InputStream直接转换第三种使用方式即可

/*** 下载远程文件, 保存到临时目录, 病生成文件信息** @param uri* @return* @throws Exception*/
private static FileInfo saveFileByURI(URI uri) throws Exception {String path = uri.getPath();if (path.endsWith("/")) {throw new IllegalArgumentException("a select uri should be choosed! but input path is: " + path);}int index = path.lastIndexOf("/");String filename = path.substring(index + 1);FileInfo fileInfo = new FileInfo();extraFileName(filename, fileInfo);fileInfo.setPath(TEMP_PATH);try {InputStream inputStream = HttpUtil.downFile(uri);return saveFileByStream(inputStream, fileInfo);} catch (Exception e) {log.error("down file from url: {} error! e: {}", uri, e);throw e;}
}

3. 输入源为InpuStream时

将输入流保存到文件

这是一个比较基础的功能了,但真正的实现起来,就没有那么顺畅了,需要注意一下几点

  • 确保临时文件所在的目录存在
  • 输入输出流的关闭,输出流的flush()方法不要忘记
  • 保存的临时文件名为: 时间戳 + [0-1000)随机数
  • 输出文件名为输入文件名的基础上加 + "_out.输出格式"
public static FileInfo saveFileByStream(InputStream inputStream, String fileType) throws Exception {// 临时文件生成规则  当前时间戳 + 随机数 + 后缀return saveFileByStream(inputStream, TEMP_PATH, genTempFileName(), fileType);
}/*** 将字节流保存到文件中** @param stream* @param filename* @return*/
public static FileInfo saveFileByStream(InputStream stream, String path, String filename, String fileType) throws FileNotFoundException {return saveFileByStream(stream, new FileInfo(path, filename, fileType));
}public static FileInfo saveFileByStream(InputStream stream, FileInfo fileInfo) throws FileNotFoundException {if (!StringUtils.isBlank(fileInfo.getPath())) {mkDir(new File(fileInfo.getPath()));}String tempAbsFile = fileInfo.getPath() + "/" + fileInfo.getFilename() + "." + fileInfo.getFileType();BufferedOutputStream outputStream = null;InputStream inputStream = null;try {inputStream = new BufferedInputStream(stream);outputStream = new BufferedOutputStream(new FileOutputStream(tempAbsFile));int len = inputStream.available();//判断长度是否大于4kif (len <= 4096) {byte[] bytes = new byte[len];inputStream.read(bytes);outputStream.write(bytes);} else {int byteCount = 0;//1M逐个读取byte[] bytes = new byte[4096];while ((byteCount = inputStream.read(bytes)) != -1) {outputStream.write(bytes, 0, byteCount);}}return fileInfo;} catch (Exception e) {log.error("save stream into file error! filename: {} e: {}", tempAbsFile, e);return null;} finally {try {if (outputStream != null) {outputStream.flush();outputStream.close();}if (inputStream != null) {inputStream.close();}} catch (IOException e) {log.error("close stream error! e: {}", e);}}
}/*** 临时文件名生成: 时间戳 + 0-1000随机数** @return*/
private static String genTempFileName() {return System.currentTimeMillis() + "_" + ((int) (Math.random() * 1000));
}/*** 递归创建文件夹** @param file 由目录创建的file对象* @throws FileNotFoundException*/
public static void mkDir(File file) throws FileNotFoundException {if (file.getParentFile().exists()) {if (!file.exists() && !file.mkdir()) {throw new FileNotFoundException();}} else {mkDir(file.getParentFile());if (!file.exists() && !file.mkdir()) {throw new FileNotFoundException();}}
}

命令行执行封装工具类 ProcessUtil

这个就是将最上面的三行代码封装的工具类,基本上快两百行...

源码先贴出

@Slf4j
public class ProcessUtil {/*** Buffer size of process input-stream (used for reading the* output (sic!) of the process). Currently 64KB.*/public static final int BUFFER_SIZE = 65536;public static final int EXEC_TIME_OUT = 2;private ExecutorService exec;private ProcessUtil() {exec = new ThreadPoolExecutor(6,12,1,TimeUnit.MINUTES,new LinkedBlockingQueue<>(10),new CustomThreadFactory("cmd-process"),new ThreadPoolExecutor.CallerRunsPolicy());}public static ProcessUtil instance() {return InputStreamConsumer.instance;}/*** 简单的封装, 执行cmd命令** @param cmd 待执行的操作命令* @return* @throws IOException* @throws InterruptedException*/public boolean process(String cmd) throws Exception {Process process = Runtime.getRuntime().exec(cmd);waitForProcess(process);return true;}/*** Perform process input/output and wait for process to terminate.** 源码参考 im4java 的实现修改而来**/private int waitForProcess(final Process pProcess)throws IOException, InterruptedException, TimeoutException, ExecutionException {// Process stdout and stderr of subprocess in parallel.// This prevents deadlock under Windows, if there is a lot of// stderr-output (e.g. from ghostscript called by convert)FutureTask<Object> outTask = new FutureTask<Object>(() -> {processOutput(pProcess.getInputStream(), InputStreamConsumer.DEFAULT_CONSUMER);return null;});exec.submit(outTask);FutureTask<Object> errTask = new FutureTask<Object>(() -> {processError(pProcess.getErrorStream(), InputStreamConsumer.DEFAULT_CONSUMER);return null;});exec.submit(errTask);// Wait and check IO exceptions (FutureTask.get() blocks).try {outTask.get();errTask.get();} catch (ExecutionException e) {Throwable t = e.getCause();if (t instanceof IOException) {throw (IOException) t;} else if (t instanceof RuntimeException) {throw (RuntimeException) t;} else {throw new IllegalStateException(e);}}FutureTask<Integer> processTask = new FutureTask<Integer>(() -> {pProcess.waitFor();return pProcess.exitValue();});exec.submit(processTask);// 设置超时时间,防止死等int rc = processTask.get(EXEC_TIME_OUT, TimeUnit.SECONDS);// just to be on the safe sidetry {pProcess.getInputStream().close();pProcess.getOutputStream().close();pProcess.getErrorStream().close();} catch (Exception e) {log.error("close stream error! e: {}", e);}return rc;}///*** Let the OutputConsumer process the output of the command.* <p>* 方便后续对输出流的扩展*/private void processOutput(InputStream pInputStream,InputStreamConsumer pConsumer) throws IOException {pConsumer.consume(pInputStream);}///*** Let the ErrorConsumer process the stderr-stream.* <p>* 方便对后续异常流的处理*/private void processError(InputStream pInputStream,InputStreamConsumer pConsumer) throws IOException {pConsumer.consume(pInputStream);}private static class InputStreamConsumer {static ProcessUtil instance = new ProcessUtil();static InputStreamConsumer DEFAULT_CONSUMER = new InputStreamConsumer();void consume(InputStream stream) throws IOException {StringBuilder builder = new StringBuilder();BufferedReader reader = new BufferedReader(new InputStreamReader(stream), BUFFER_SIZE);String temp;while ((temp = reader.readLine()) != null) {builder.append(temp);}if (log.isDebugEnabled()) {log.debug("cmd process input stream: {}", builder.toString());}reader.close();}}private static class CustomThreadFactory implements ThreadFactory {private String name;private AtomicInteger count = new AtomicInteger(0);public CustomThreadFactory(String name) {this.name = name;}@Overridepublic Thread newThread(Runnable r) {return new Thread(r, name + "-" + count.addAndGet(1));}}}

说明

  • 内部类方式的单例模式
  • 线程池,开独立的线程来处理命令行的输出流、异常流
    • 如果不清空这两个流,可能直接导致rt随着并发数的增加而线性增加
  • 独立的线程执行命令行操作,支持超时设置
    • 超时设置,确保服务不会挂住
    • 异步执行命令行操作,可以并发执行后续的步骤

填坑之旅

上面实现了一个较好用的封装类,但是在实际的开发过程中,有些问题有必要单独的拎出来说一说

1. -y 参数

覆盖写,如果输出的文件名对应的文件已经存在,这个参数就表示使用新的文件覆盖老的

在控制台执行转码时,会发现这种场景会要求用户输入一个y/n来表是否继续转码,所以在代码中,如果不加上这个参数,将一直得不到执行

2. mac/ios 的音频长度与实际不符合

将 amr 音频转换 mp3 格式音频,如果直接使用命令ffmpeg -i test.amr -y out.mp3

会发现输出的音频时间长度比实际的小,但是在播放的时候又是没有问题的;测试在mac和iphone会有这个问题

解决方案,加一个参数 write_xing 0

3. 并发访问时,RT线性增加

执行命令: ffmpeg -i song.ogg -y -write_xing 0 song.mp3

当我们没有手动清空输出流,异常流时,会发现并发请求量越高,rt越高

主要原因是输出信息 & 异常信息没有被消费,而缓存这些数据的空间是有限制的,因此上面我们的ProcessUtil类中,有两个任务来处理输出流和异常流

还有一种方法就是加一个参数

ffmpeg -i song.ogg -y -write_xing 0 song.mp3 -loglevel quiet

其他

项目源码: https://github.com/liuyueyi/quick-media

个人博客主页: 一灰的博客网站

公众号获取更多:

G

转载于:https://my.oschina.net/u/566591/blog/1359432

spring-boot ffmpeg 搭建一个音频转码服务相关推荐

  1. 《SpringCloud超级入门》Spring Boot项目搭建步骤(超详细)《六》

    目录 编写第一个 REST 接口 读取配置文件 profiles 多环境配置 热部署 actuator 监控 自定义 actuator 端点 统一异常处理 异步执行 随机端口 编译打包 在 Sprin ...

  2. 【Spring Boot】使用Spring Boot来搭建Java web项目以及开发过程

    [Spring Boot]使用Spring Boot来搭建Java web项目以及开发过程 一.Spring Boot简介 Spring Boot是由Pivotal团队提供的全新框架,其设计目的是用来 ...

  3. Spring Boot 面试,一个问题就干趴下了!

    最近栈长面试了不少人,其中不乏说对 Spring Boot 非常熟悉的,然后当我问到一些 Spring Boot 核心功能和原理的时候,没人能说得上来,或者说不到点上,可以说一个问题就问趴下了! 这是 ...

  4. Spring Boot Dubbo 应用启停源码分析

    作者:张乎兴 来源:Dubbo官方博客 背景介绍 Dubbo Spring Boot 工程致力于简化 Dubbo RPC 框架在Spring Boot应用场景的开发.同时也整合了 Spring Boo ...

  5. Spring Boot 2.x 启动全过程源码分析(全)

    上篇<Spring Boot 2.x 启动全过程源码分析(一)入口类剖析>我们分析了 Spring Boot 入口类 SpringApplication 的源码,并知道了其构造原理,这篇我 ...

  6. Spring Boot 2.x 启动全过程源码分析(上)入口类剖析

    转载自   Spring Boot 2.x 启动全过程源码分析(上)入口类剖析 Spring Boot 的应用教程我们已经分享过很多了,今天来通过源码来分析下它的启动过程,探究下 Spring Boo ...

  7. Spring Boot框架搭建

    目录 一.Spring Boot概述 二.Spring Boot的优点 三.Spring Boot框架搭建 一.Spring Boot概述 Spring Boot 是 Spring 框架的一个新的子项 ...

  8. spring boot ELK搭建

    ELK简介 ELK是Elasticsearch+Logstash+Kibana简称 Elasticsearch 是一个分布式的搜索和分析引擎,可以用于全文检索.结构化检索和分析,并能将这三者结合起来. ...

  9. Spring Boot 面试,一个问题就干趴下了。

    我看你上面写了熟悉 Spring Boot,那你能讲下为什么我们要用 Spring Boot 吗? 下面我列几个最常见的三个回答: A:Spring Boot 最主要是不用 XML 配置,可以用 Ja ...

最新文章

  1. string 类的初始化和赋值(程序成长之路的一颗米)
  2. addr2line 和 tombstone问题分析
  3. 3.6 SM30维护表数据
  4. Mybatis的批量更新 bug
  5. 小笔记,在windows和linux下分开编译、在C\C++下都使用C风格编译
  6. 董明珠自媒体:格力口罩今日开售 上午预约下午抢购
  7. [poj3280]Cheapest Palindrome_区间dp
  8. 使用 PDO 方式将 Session 保存到 MySQL 数据中
  9. 软件教程给MyEclipse 10增加SVN功能
  10. 免费java版我的世界下载教程,我的世界java版下载,我的世界java版下载教程
  11. git 中怎样查看未传送(git push)到远程代码库的(git commit)提交?
  12. vb.net多线程例子
  13. sql注入数据库原理详解
  14. 初识Centos7.5
  15. python openpyxl怎么将数组写入excel_Python-使用openpyxl模块写入Excel文件
  16. NS2学习---可视化Tcl生成工具NSG2
  17. 【ACWing】487. 金明的预算方案
  18. 我那迷途知返的小羊-linux修复DNS解析问题
  19. RK 100 上手体验 — 机械键盘客制化入门之选?
  20. js中的console.log()用法

热门文章

  1. 华为鸿蒙5g售价,华为首款5G手机售价公布,余承东透露鸿蒙将用于连接家庭设备...
  2. 怎么编写java_程序员学编程第一步:手把手教你开发第一个Java程序
  3. 3_python基础—运算符 1
  4. python中debug有什么用途_Python debug 总结
  5. java继承父类执行顺序_java中子类继承父类程序执行顺序问题
  6. python中的一些基础
  7. 输入框回车多个文本_输入框测试用例,你真的了解输入框测试嘛!
  8. vim python 代码提示_linux下vim python代码自动补全
  9. python spyder跑出的数据部分有些不变是怎么回事_解决Python spyder显示不全df列和行的问题...
  10. ubuntu display