来源:https://mnt.io/2018/10/29/from-rust-to-beyond-the-php-galaxy/

译注:原作者换工作到Wasmer,后续绑定相关文章没有再更新。

这篇博客文章是这一系列解释如何将Rust发射到地球以外的许多星系的文章的一部分:

  • 前奏,

  • WebAssembly 星系

  • ASM.js星系

  • C星系

  • PHP星系(当前这一集)

  • NodeJS 星系

今天将要探索的是PHP星系。这篇文章会解释什么是PHP,以及如何将任何的Rust程序编译为C进而制作PHP的原生扩展。

什么是PHP,为什么?

PHP 是:

流行的通用脚本语言,特别适合Web开发。从您的博客到世界上最流行的网站,PHP提供了快速、灵活和实用的功能。

令人遗憾的是,PHP多年来名声不佳,但是最近的版本(主要是从PHP 7.0开始的)引入了简洁的语言特性和许多清理优化,这些特性都被讨厌它的人过分忽略了。PHP也是一种快速脚本语言,并且非常灵活。PHP现在已经有了声明类型、特征、可变参数、闭包(带有显式范围!)、生成器等特性和强大的向后兼容能力。PHP的开发是由RFC主导的,过程开放、民主。Gutenberg项目是WordPress的一个新编辑器,因为Wordpress是用PHP编写的,很自然的我们需要一个PHP的原生扩展来解析Gutenberg博客格式。PHP是一种有规范的语言(意味着可以有不同的虚拟机实现方案)。最流行的虚拟机是Zend Engine, 其他虚拟机也存在,比如HHVM(但是PHP支持最近被放弃,转而支持它们自己的PHP fork,称为Hack)、Peachpie或Tagua VM(正在开发中)。在本文中,我们将为Zend Engine创建一个扩展。注意这个虚拟机是用C语言编写的,很棒的是我们已经在前面一篇文章登陆了C星系!

Rust ? C ? PHP

要将Rust解析器移植到PHP中,我们首先需要将它移植到C。这在上一节中已经完成。移植到C的结果就是两个文件: libgutenberg_post_parser.a 和 gutenberg_post_parser.h,分别为静态库和头文件。

从脚手架开始

PHP附带一个脚本来创建一个扩展框架模板或者说脚手架,叫做ext_skel.php。这个脚本可以从Zend引擎虚拟机的源代码找到(我们把它叫做php-src)。可以像这样调用脚本

$ cd php-src/ext/
$ ./ext_skel.php \--ext gutenberg_post_parser \--author 'Ivan Enderlin' \--dir /path/to/extension \--onlyunix
$ cd /path/to/extension
$ ls gutenberg_post_parser
tests/
.gitignore
CREDITS
config.m4
gutenberg_post_parser.c
php_gutenberg_post_parser.h

ext_skel.php建议执行以下步骤:

  • 重新编译PHP源代码的配置文件(在php-src的根目录运行./buildconf

  • 重新配置构建系统,启用扩展,这样:./configure --enable-gutenberg_post_parser

  • Make进行构建

  • 完成

但是我们的扩展很可能位于php-src树之外。所以我们将使用phpizephpize是一个可执行文件,是随php一起安装的, 还有如php-cgiphpdbgphp-config等。它允许根据已经编译好的php二进制文件编译扩展,这正好完美的满足了我们的需求!我们将这样使用它

$ cd /path/to/extension/gutenberg_post_parser$ # Get the bin directory for PHP utilities.
$ PHP_PREFIX_BIN=$(php-config --prefix)/bin$ # Clean (except if it is the first run).
$ $PHP_PREFIX_BIN/phpize --clean$ # “phpize” the extension.
$ $PHP_PREFIX_BIN/phpize$ # Configure the extension for a particular PHP version.
$ ./configure --with-php-config=$PHP_PREFIX_BIN/php-config$ # Compile.
$ make install

在这篇文章中,我们将不展示我们所做的所有编辑,而是将重点放在扩展绑定上。所有的资料都可以在这里找到。下面是config.m4文件:

PHP_ARG_ENABLE(gutenberg_post_parser, whether to enable gutenberg_post_parser support,
[  --with-gutenberg_post_parser          Include gutenberg_post_parser support], no)if  test "$PHP_GUTENBERG_POST_PARSER" != "no"; thenPHP_SUBST(GUTENBERG_POST_PARSER_SHARED_LIBADD)PHP_ADD_LIBRARY_WITH_PATH(gutenberg_post_parser, ., GUTENBERG_POST_PARSER_SHARED_LIBADD)PHP_NEW_EXTENSION(gutenberg_post_parser, gutenberg_post_parser.c, $ext_shared)
fi

它做的事情基本上是这样的:

  • 在构建系统里面注册参数:--with-gutenberg_post_parser

  • 声明要编译的静态库以及扩展本身的源代码

我们必须添加libgutenberg_post_parser.agutenberg_post_parser.h文件在同一个目录下(符号链接是完美支持的),到这样的结构:

$ ls gutenberg_post_parser
tests/                       # from ext_skel
.gitignore                   # from ext_skel
CREDITS                      # from ext_skel
config.m4                    # from ext_skel (edited)
gutenberg_post_parser.c      # from ext_skel (will be edited)
gutenberg_post_parser.h      # from Rust
libgutenberg_post_parser.a   # from Rust
php_gutenberg_post_parser.h  # from ext_skel

扩展最核心的是gutenberg_post_parser.c文件。这个文件负责创建模块,并将我们的Rust码绑定到PHP。

模块/扩展

如前所述,我们来写gutenberg_post_parser.c文件。首先,include所有需要的东西:

#include "php.h"
#include "ext/standard/info.h"
#include "php_gutenberg_post_parser.h"
#include "gutenberg_post_parser.h"

最后一行include由Rust生成的gutenberg_post_parser.h 文件(更准确地说,是由cbindgen生成的,如果您不记得了,请查看前一集)。然后,我们必须决定要向PHP暴露什么API ?提醒一下,Rust解析器生成如下的AST定义:

pub enum Node<'a> {Block {name: (Input<'a>, Input<'a>),attributes: Option<Input<'a>>,children: Vec<Node<'a>>},Phrase(Input<'a>)
}

C版本AST和这个非常相似(具有更多的结构,但是思想几乎相同)。在PHP中,我们用以下结构

class Gutenberg_Parser_Block {public string $namespace;public string $name;public string $attributes;public array $children;
}class Gutenberg_Parser_Phrase {public string $content;
}
function gutenberg_post_parse(string $gutenberg_post): array;

gutenberg_post_parse函数将输出一个对象数组,对象类型为Gutenberg_Parser_BlockGutenberg_Parser_Phrase即我们的AST。下面我们来声明这些类!

声明类

注意:后面的4个代码块不是本文的核心,它只是需要编写的代码,如果不打算编写一个PHP扩展,可以跳过它。

zend_class_entry *gutenberg_parser_block_class_entry;
zend_class_entry *gutenberg_parser_phrase_class_entry;
zend_object_handlers gutenberg_parser_node_class_entry_handlers;typedef struct _gutenberg_parser_node {zend_object zobj;
} gutenberg_parser_node;

class_entry表示特定的类类型。 会有一个handlerclass_entry相关联。逻辑有点复杂。如果您需要更多详细信息,我建议您阅读PHP内部原理这本书。然后,让我们创建一个函数来即时处理这些对象

static zend_object *create_parser_node_object(zend_class_entry *class_entry)
{gutenberg_parser_node *gutenberg_parser_node_object;gutenberg_parser_node_object = ecalloc(1, sizeof(*gutenberg_parser_node_object) + zend_object_properties_size(class_entry));zend_object_std_init(&gutenberg_parser_node_object->zobj, class_entry);object_properties_init(&gutenberg_parser_node_object->zobj, class_entry);gutenberg_parser_node_object->zobj.handlers = &gutenberg_parser_node_class_entry_handlers;return &gutenberg_parser_node_object->zobj;
}

然后我们创建一个函数来释放这些对象。需要两步:通过调用析构函数来析构对象(在用户态),然后真正的释放它(在虚拟机中)

static void destroy_parser_node_object(zend_object *gutenberg_parser_node_object)
{zend_objects_destroy_object(gutenberg_parser_node_object);
}static void free_parser_node_object(zend_object *gutenberg_parser_node_object)
{zend_object_std_dtor(gutenberg_parser_node_object);
}

然后初始化模块/扩展。初始化的过程中我们将在用户态创建类以及声明其属性等。

PHP_MINIT_FUNCTION(gutenberg_post_parser)
{zend_class_entry class_entry;// Declare Gutenberg_Parser_Block.INIT_CLASS_ENTRY(class_entry, "Gutenberg_Parser_Block", NULL);gutenberg_parser_block_class_entry = zend_register_internal_class(&class_entry TSRMLS_CC);// Declare the create handler.gutenberg_parser_block_class_entry->create_object = create_parser_node_object;// The class is final.gutenberg_parser_block_class_entry->ce_flags |= ZEND_ACC_FINAL;// Declare the `namespace` public attribute,// with an empty string for the default value.zend_declare_property_string(gutenberg_parser_block_class_entry, "namespace", sizeof("namespace") - 1, "", ZEND_ACC_PUBLIC);// Declare the `name` public attribute,// with an empty string for the default value.zend_declare_property_string(gutenberg_parser_block_class_entry, "name", sizeof("name") - 1, "", ZEND_ACC_PUBLIC);// Declare the `attributes` public attribute,// with `NULL` for the default value.zend_declare_property_null(gutenberg_parser_block_class_entry, "attributes", sizeof("attributes") - 1, ZEND_ACC_PUBLIC);// Declare the `children` public attribute,// with `NULL` for the default value.zend_declare_property_null(gutenberg_parser_block_class_entry, "children", sizeof("children") - 1, ZEND_ACC_PUBLIC);// Declare the Gutenberg_Parser_Block.… skip …// Declare Gutenberg parser node object handlers.memcpy(&gutenberg_parser_node_class_entry_handlers, zend_get_std_object_handlers(), sizeof(gutenberg_parser_node_class_entry_handlers));gutenberg_parser_node_class_entry_handlers.offset = XtOffsetOf(gutenberg_parser_node, zobj);gutenberg_parser_node_class_entry_handlers.dtor_obj = destroy_parser_node_object;gutenberg_parser_node_class_entry_handlers.free_obj = free_parser_node_object;return SUCCESS;
}

如果你还在阅读,首先:谢谢,其次:恭喜!然后,有一个PHP_RINIT_FUNCTION 函数和PHP_MINFO_FUNCTION函数,这些函数已经由ext_skel.php脚本生成。对于模块定义和其他模块配置细节也是如此。

gutenberg_post_parse函数

现在我们将关注gutenberg_post_parse这个PHP函数。该函数需要一个字符串类型的参数,如果解析失败,则返回false,否则返回Gutenberg_Parser_Block 或者 Gutenberg_Parser_Phrase的对象数组。让我们写下来!注意,它是用PHP函数宏声明的。

PHP_FUNCTION(gutenberg_post_parse)
{char *input;size_t input_len;// Read the input as a string.if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "s", &input, &input_len) == FAILURE) {return;}

在此步骤中,参数已被声明类型为字符串(“s”)。字符串值在input中,字符串长度在input_len中。下一步是解析输入。(不需要字符串的长度)。这就是我们要调用Rust代码的地方!我们来写一下:

    // Parse the input.Result parser_result = parse(input);// If parsing failed, then return false.if (parser_result.tag == Err) {RETURN_FALSE;}// Else map the Rust AST into a PHP array.const Vector_Node nodes = parse_result.ok._0;

Result类型和parse函数来自Rust。如果你不记得这些类型,请阅读前一集关于C星系的内容。Zend Engine有一个名为RETURN_FALSE的宏来返回false!很贴心是不是?最后如果一起顺利,我们会得到一个节点集合,节点类型为Vector_Node。下一步是要映射这些Rust/C类型到PHP的类型,也就是Gutenberg类的数组。开始:

    // Note: return_value is a “magic” variable that holds the value to be returned.//// Allocate an array.array_init_size(return_value, nodes.length);// Map the Rust AST.into_php_objects(return_value, &nodes);
}

完成!等一下。。。into_php_objects好像还没有写!

into_php_objects函数

这个函数并不十分复杂:它只是像预期的那样充满了特定于Zend引擎的API。我们将解释如何将一个Block映射到 Gutenberg_Parser_Block,并让Phrase映射到Gutenberg_Parser_Phrase,以方便勤奋的读者。代码:

void into_php_objects(zval *php_array, const Vector_Node *nodes)
{const uintptr_t number_of_nodes = nodes->length;if (number_of_nodes == 0) {return;}// Iterate over all nodes.for (uintptr_t nth = 0; nth < number_of_nodes; ++nth) {const Node node = nodes->buffer[nth];if (node.tag == Block) {// Map Block into Gutenberg_Parser_Block.} else if (node.tag == Phrase) {// Map Phrase into Gutenberg_Parser_Phrase.}}
}

映射block的过程如下:

  1. blocknamespace分配一个PHP字符串,block的名字也需要这样,

  2. 分配一个对象,

  3. 设置block namespace 和 block name相应的属性,

  4. 为必要block属性的PHP字符串,

  5. 设置block属性到对应的对象,

  6. 如果有子节点,初始化一个数组, 然后用child节点和新数组调用into_php_objects函数,

  7. 设置children到对应的对象,

  8. 最后,把block对象追加到将要返回的数组里面。

const Block_Body block = node.block;
zval php_block, php_block_namespace, php_block_name;// 1. Prepare the PHP strings.
ZVAL_STRINGL(&php_block_namespace, block.namespace.pointer, block.namespace.length);
ZVAL_STRINGL(&php_block_name, block.name.pointer, block.name.length);

您还记得namespacename和其他类似的数据都属于Slice_c_char类型吗?它只是一个有指针和长度的结构。指针指向原始的输入字符串,因此没有副本(实际上这是Slice的定义)。Zend Engine有一个ZVAL_STRINGL宏,它允许从指针和长度创建字符串,太棒了!不幸的是,对于我们来说,Zend Engine在后台做了一个复制使得没有办法只保留指针和长度,但是它做到了只用很小复制的数量。我认为它之所以需要完全拥有数据所有权,是因为垃圾回收需要这个。

// 2. Create the Gutenberg_Parser_Block object.
object_init_ex(&php_block, gutenberg_parser_block_class_entry);

对象已经用gutenberg_parser_block_class_entry表示的类进行了实例化。

// 3. Set the namespace and the name.
add_property_zval(&php_block, "namespace", &php_block_namespace);
add_property_zval(&php_block, "name", &php_block_name);zval_ptr_dtor(&php_block_namespace);
zval_ptr_dtor(&php_block_name);
The zval_ptr_dtor adds 1 to the reference counter. This is required for the garbage collector.
// 4. Deal with block attributes if some.
if (block.attributes.tag == Some) {Slice_c_char attributes = block.attributes.some._0;zval php_block_attributes;ZVAL_STRINGL(&php_block_attributes, attributes.pointer, attributes.length);// 5. Set the attributes.add_property_zval(&php_block, "attributes", &php_block_attributes);zval_ptr_dtor(&php_block_attributes);
}

namespacename的操作一样,我们完成children

// 6. Handle children.
const Vector_Node *children = (const Vector_Node*) (block.children);if (children->length > 0) {zval php_children_array;array_init_size(&php_children_array, children->length);// Recursion.into_php_objects(&php_children_array, children);// 7. Set the children.add_property_zval(&php_block, "children", &php_children_array);Z_DELREF(php_children_array);
}free((void*) children);

最后,追加这个block示例到返回数组:

// 8. Insert the object in the collection.
add_next_index_zval(php_array, &php_block);

所有的代码可以到这里找到

PHP扩展 ? PHP用户态

现在扩展已经写好了,我们必须编译它。这就是我们在上面用phpize所显示的重复命令集。编译扩展之后,生成的generated gutenberg_post_parser.so库文件必须位于扩展目录中。可以使用以下命令找到此目录

$ php-config --extension-dir

比如在我的电脑上,扩展目录是/usr/local/Cellar/php/7.2.11/pecl/20170718。然后,要为给定的执行启用扩展,必须这样写:

$ php -d extension=gutenberg_post_parser -m | \grep gutenberg_post_parser

或者,为所有的执行都开启这个扩展,用php -ini找到PHP的配置文件php.ini,增加:

extension=gutenberg_post_parser

完成!现在,让我们使用一些反射来检查扩展是否被PHP正确加载和处理:

$ php --re gutenberg_post_parser
Extension [ <persistent> extension #64 gutenberg_post_parser version 0.1.0 ] {- Functions {Function [ <internal:gutenberg_post_parser> function gutenberg_post_parse ] {- Parameters [1] {Parameter #0 [ <required> $gutenberg_post_as_string ]}}}- Classes [2] {Class [ <internal:gutenberg_post_parser> final class Gutenberg_Parser_Block ] {- Constants [0] {}- Static properties [0] {}- Static methods [0] {}- Properties [4] {Property [ <default> public $namespace ]Property [ <default> public $name ]Property [ <default> public $attributes ]Property [ <default> public $children ]}- Methods [0] {}}Class [ <internal:gutenberg_post_parser> final class Gutenberg_Parser_Phrase ] {- Constants [0] {}- Static properties [0] {}- Static methods [0] {}- Properties [1] {Property [ <default> public $content ]}- Methods [0] {}}}
}

一切看起来都很好:有一个函数和两个类。现在,让我们在这篇博客文章中首次编写一些PHP代码

<?phpvar_dump(gutenberg_post_parse('<!-- wp:foo /-->bar<!-- wp:baz -->qux<!-- /wp:baz -->')
);/*** Will output:*     array(3) {*       [0]=>*       object(Gutenberg_Parser_Block)#1 (4) {*         ["namespace"]=>*         string(4) "core"*         ["name"]=>*         string(3) "foo"*         ["attributes"]=>*         NULL*         ["children"]=>*         NULL*       }*       [1]=>*       object(Gutenberg_Parser_Phrase)#2 (1) {*         ["content"]=>*         string(3) "bar"*       }*       [2]=>*       object(Gutenberg_Parser_Block)#3 (4) {*         ["namespace"]=>*         string(4) "core"*         ["name"]=>*         string(3) "baz"*         ["attributes"]=>*         NULL*         ["children"]=>*         array(1) {*           [0]=>*           object(Gutenberg_Parser_Phrase)#4 (1) {*             ["content"]=>*             string(3) "qux"*           }*         }*       }*     }*/

工作得很好!

结论

这个旅程是这样的:

  • 一个PHP的string,

  • 在Gutenberg扩展中分配属于Zend Engine,

  • 通过FFI传递给Rust(静态库 + 头文件),

  • 从Gutenberg扩展回到Zend Engine,

  • 生成PHP对象,

  • PHP得到对象。

到处都适用Rust!我们已经看到在现实世界中如何用Rust编写一个解析器,如何将其绑定到C然后编译到一个静态库和C头文件,如何创建一个PHP扩展暴露一个函数和两个对象,如何将C绑定集成到PHP中,以及如何在PHP中使用这个扩展。提醒一下,C绑定大约有150行代码。PHP扩展大约有300行代码,但是减去自动生成的修饰后(声明和管理扩展的样板代码),PHP扩展减少到大约200行代码。再一次,可以看到我们需要review的代码面是很小的,因为考虑到解析器仍然是用Rust编写的,修改解析器不会影响绑定(除非AST明显更新)! PHP是一种带有垃圾收集器的语言。这解释了为什么要复制所有字符串,以便它们都属于PHP本身。然而,Rust不复制任何数据的事实节省了内存分配和释放,这在大多数情况下是最大的成本。Rust也提供了安全。考虑到我们要处理的绑定数量,可以对这个属性提出疑问: Rust到C到PHP: 这还安全么?从Rust的角度来看,答案是肯定的,但是在C或PHP中发生的所有事情都必须被认为是不安全的。在C绑定中必须特别注意处理所有情况。还快吗?我们来做个基准测试。我想提醒您,这个实验的第一个目标是解决原始PEG.js解析器的性能问题。在JavaScript方面,WASM和ASM.js已经显示出了非常快的速度(参见WebAssembly galaxy和ASM.js galaxy)。对于PHP,我们使用phpegjs:它读取为PEG.js编写的语法并将其编译到PHP。我们来比较一下

file PEG PHP parser (ms) Rust parser as a PHP extension (ms) speedup
demo-post.html 30.409 0.0012 × 25341
shortcode-shortcomings.html 76.39 0.096 × 796
redesigning-chrome-desktop.html 225.824 0.399 × 566
web-at-maximum-fps.html 173.495 0.275 × 631
early-adopting-the-future.html 280.433 0.298 × 941
pygmalian-raw-html.html 377.392 0.052 × 7258
moby-dick-parsed.html 5,437.630 5.037 × 1080

Rust解析器的PHP扩展比实际的PEG PHP实现平均快5230倍。提速的中位数是941。另一个大问题是PEG解析器由于内存限制无法处理许多个Gutenberg文档。当然,增大内存的大小是可能的,但并不理想。使用Rust解析器作为PHP扩展,内存保持大小不变,并且和被解析文档的大小接近。我认为我们可以进一步优化扩展来生成迭代器而不是数组,这是我想探索东西以及分析其对性能的影响。The PHP Internals Book中就有一章是关于迭代器的。我们将在本系列的下一集看到Rust可以到达很多星系,Rust越多的往后旅行,也会变得更加有趣。谢谢你的阅读。

从Rust到远方:PHP星系相关推荐

  1. 从Rust到远方:WebAssembly 星系

    来源:https://mnt.io/2018/08/22/from-rust-to-beyond-the-webassembly-galaxy/ 这篇博客文章是这一系列解释如何将Rust发射到地球以外 ...

  2. 从Rust到远方:ASM.js星系

    来源: https://mnt.io/2018/08/28/from-rust-to-beyond-the-asm-js-galaxy/ 这篇博客文章是这一系列解释如何将Rust发射到地球以外的许多星 ...

  3. Rust和C / C ++的跨语言链接时间优化LTO

    Rust和C / C ++的跨语言链接时间优化LTO 链接时间优化(LTO)是LLVM实施整个程序优化的方法.跨语言LTO是Rust编译器中的一项新功能,使LLVM的链接时间优化可以在混合的C / C ...

  4. 10玩rust_有趣的 Rust 类型系统: Trait

    也许你已经学习了标准库提供的 String 类型,这是一个 UTF-8 编码的可增长字符串.该类型的结构为: pub struct String {vec: Vec<u8>, } UTF- ...

  5. rust熔炉怎么带走_Rust游戏中12个实用小技巧,包含无伤下坠、直梯爬楼

    Rust是一款第一人称生存网络游戏,有点像野外求生,但这款游戏内容则更加丰富.刺激.血腥. 在这款游戏中玩家的第一任务就是活下来,而想要活下来你将要接受饥饿.干渴.寒冷等.游戏中玩家需要建造自己的庇护 ...

  6. 腐蚀rust电脑分辨率调多少_腐蚀Rust怎么设置画面 腐蚀Rust提高帧数画面设置方法...

    腐蚀Rust这个游戏的细节取决于图像质量也就是我们进入游戏的时候可以选择画质,这里为大家带来腐蚀Rust画质设置教程. 图像质量 1~3为一个大档 4~5是一个大档 4以上你在游戏里面的画面会显示更多 ...

  7. rust 官服指令_RUST 命令大全(包括服务器指令)

    该楼层疑似违规已被系统折叠 隐藏此楼查看此楼 RUST++ MOD (以下在聊天框内输入) 基本命令 /share playername [shares your doors with a playe ...

  8. 30005 rust_Steam三连冠老游戏《腐蚀(RUST)》为什么突然火起来了?

    Steam新一周(1月18日-1月24日)销量榜公开,<赛博朋克2077>跌落至第五,<荒野大镖客2>前进到第六,而第一人称僵尸生存网络游戏<Rust>已经三连冠了 ...

  9. 使用Rust + Electron开发跨平台桌面应用 ( 一 )

    前言 近段时间学习了Rust,一直想着做点什么东西深入学习,因为是刚学习,很多地方都不熟悉,所以也就不能拿它来做编译器这些,至于web开发,实际上我并不建议拿这个来学习一门语言,大概有几个方面,一是w ...

最新文章

  1. 【项目管理】工件清单说明
  2. 机器学习导论(张志华):条件期望
  3. Java中的内存泄露的几种可能
  4. python list存储对象_《python解释器源码剖析》第4章--python中的list对象
  5. 当你和天猫精灵对话时,它在想什么?阿里智能对话技术深度解读
  6. Microsoft .NET Pet Shop 4.0 学习之旅(二) - 项目的引用关系1
  7. C#读写XML的两种一般方式
  8. find ctime 加减n时间范围
  9. EBT 道客巴巴的加密与破解 -免费下载器的基础
  10. malloc函数java_malloc函数详解及用法举例
  11. 微信终于能注册小号了,无需绑定手机号!
  12. pandas学习笔记-DataFrame(2)
  13. 微信小程序生成带logo二维码
  14. ofo小黄跑路,中国人素质真的差吗?
  15. 图片如何降低分辨率 ?如何缩小照片分辨率不改变图像大小?
  16. 使用ADB命令卸载软件
  17. 远程往服务器上传送文件,服务器远程传送文件
  18. 2020计算机二级报名时间表下半年山东,2020年3月山东省计算机二级报名时间|网上报名入口【12月20日9:00开通】...
  19. Ubuntu 笔记本麦克风没有声音解决方法
  20. arm 各种 gcc 编译器区别

热门文章

  1. 一种二阶Biquad滤波器
  2. 对applyTo和renderTo的理解和思考
  3. 4、ArrayList的详细扩容过程
  4. 记录几个 Android x86 系统的官网
  5. Python爬取高清无版权美图
  6. java wsimport方式生成webservice客户端代码
  7. C++ 模板与泛型编程简述
  8. IDEA安装Activiti画图插件
  9. HAL库开发BMP280读取压强
  10. 大学生怎样求职简历怎么写自我介绍?简历写作秘籍