PHP 性能效率详解

现在,我们已经介绍了与函数式编程相关的各种技术,现在是分析它如何影响 PHP 等语言的性能的时候了,尽管每个版本都引入了越来越多的函数特性,但 PHP 仍然是其核心。

我们还将讨论为什么性能最终不那么重要,以及在某些情况下如何利用记忆和其他技术来缓解这个问题。

我们还将探讨通过引用透明性实现的两种优化技术。第一个是 memonization,这是一种缓存。我们还将讨论并行运行长计算,以及如何在 PHP 中利用这一点。

在本章中,我们将介绍以下主题:

由于没有对诸如 currying 和函数组合之类的功能的核心支持,因此需要使用匿名包装函数来模拟它们。显然,这会带来性能成本。此外,正如我们已经在第 7 章函数技术和主题中讨论的尾部调用递归部分中所述,使用蹦床的速度也较慢。但与更传统的方法相比,您会损失多少执行时间?

让我们创建一些函数作为基准,并测试我们可以实现的各种速度。该函数将执行一个非常简单的任务,添加两个数字,以确保我们尽可能有效地测量开销:

<?php 

use Functional as f; 

function add($a, $b) 
{ 
    return $a + $b; 
} 

function manualCurryAdd($a, $b = null) { 
    $func = function($b) use($a) { 
        return $a + $b; 
    }; 

    return func_num_args() > 1 ? $func($b) : $func; 
} 

$curryiedAdd = f\curry('add'); 

function add2($b) 
{ 
    return $b + 2; 
} 

function add4($b) 
{ 
    return $b + 4; 
} 

$composedAdd4 = f\compose('add2', 'add2'); 

$composerCurryedAdd = f\compose($curryiedAdd(2), $curryiedAdd(2)); 

我们创建了第一个函数add并进行了 curryid;这将是我们的第一个基准。然后,我们将比较一个专门的函数,将4添加到两个不同组合的值中。第一个是两个专门函数的组合,第二个是add函数的两个当前版本的组合。

我们将使用以下代码对我们的函数进行基准测试。这是非常基本的,但足以证明任何有意义的差异:

<?php 

use Oefenweb\Statistics\Statistics; 

function benchmark($function, $params, $expected) 
{ 
    $iteration   = 10; 
    $computation = 2000000; 

    $times = array_map(function() use($computation, $function,  $params, $expected) { 
        $start = microtime(true); 

        array_reduce(range(0, $computation), function($expected)  use ($function, $params) { 
            if(($res = call_user_func_array($function, $params))  !== $expected) { 
                throw new RuntimeException("Faulty computation"); 
            } 

            return $expected; 
        }, $expected); 

        return microtime(true) - $start; 
    }, range(0, $iteration)); 

    echo sprintf("mean: %02.3f seconds\n",  Statistics::mean($times)); 
    echo sprintf("std:  %02.3f seconds\n",  Statistics::standardDeviation($times)); } 

统计方法来自通过 composer 提供的oefenweb/statistics包。作为额外的预防措施,我们还检查返回的值是否是我们期望的值。我们将连续 10 次运行每个函数 200 万次,并显示 200 万次运行的平均时间。

让我们先运行 currying 的基准测试。显示的结果适用于 PHP7.0.12。在 PHP 5.6 中尝试这一点时,所有基准测试都比较慢,但它们在不同的函数之间表现出相同的差异:

<?php 

benchmark('add', [21, 33], 54); 
// mean: 0.447 seconds 
// std:  0.015 seconds 

benchmark('manualCurryAdd', [21, 33], 54); 
// mean: 1.210 seconds 
// std:  0.016 seconds 

benchmark($curryiedAdd, [21, 33], 54); 
// mean: 1.476 seconds 
// std:  0.007 seconds 

显然,结果将因运行测试的系统而异,但相对差异应保持大致相同。

首先,如果我们看一下标准差,我们可以看到 10 次运行中的每一次都花费了几乎相同的时间,这表明我们可以相信我们的数字是一个很好的性能指标。

我们可以看到当前版本的速度明显较慢。手动咖喱有点效率,但两种咖喱版本的速度都比简单功能版本慢三倍。在得出结论之前,让我们先看看组合函数的结果:

<?php 

benchmark('add4', [10], 14); 
// mean: 0.434 seconds 
// std:  0.001 seconds 

benchmark($composedAdd4, [10], 14); 
// mean: 1.362 seconds 
// std:  0.005 seconds 

benchmark($composerCurryedAdd, [10], 14); 
// mean: 3.555 seconds 
// std:  0.018 seconds 

再次,标准偏差足够小,所以我们可以认为数字是有效的。

关于值本身,我们可以看到合成速度也慢了三倍,而 curryied 函数的合成速度慢了九倍,这一点毫不奇怪。

现在,如果我们将最坏的情况下的3.55秒与最好的情况下的0.434秒比较,这意味着我们在使用构图和咖喱时有3秒的开销。这有关系吗?这看起来是不是浪费了很多时间?让我们试着在 web 应用的上下文中想象这些数字。

管理费用重要吗?

我们对我们的方法执行了 200 万次,执行时间为 3 秒。我最近参与的一个项目是一个奢侈品品牌的电子商务应用,该应用在 26 个国家和 10 多种语言中提供,完全是在没有任何框架帮助的情况下从头开始编写的,一个页面上的函数调用大约有 25000 个。

即使我们承认所有这些调用都是对预先生成的组合函数进行的,这意味着在最坏的情况下,开销现在大约为 40 毫秒。这个应用显示一个页面大约需要 180 毫秒,所以我们说的是性能下降了 20-25%。

这仍然是一个很大的数字,但远不是我们以前看到的三倍慢的数字。与函数技术相关的开销将随着每次函数调用而线性增长。在基准测试中,它看起来很棒,因为执行的计算很简单。在实际应用中,存在外部瓶颈,如数据库、第三方 API 或文件系统。还有一些函数执行复杂的计算,比简单的加法花费更多的时间。在这种情况下,引入的开销只占应用总执行时间的一小部分。

这也是一种最坏的情况,我们假设所有的东西都是合成的,并且都是固定的。在实际应用中,您可能会使用传统的框架,其中包含函数和方法,而不会产生开销。您还可以识别代码中的热路径,并使用显式的 curry 和 composition(而不是 helper 函数)手动优化它们。也没有必要事事讨好;您将拥有只包含一个不需要它的参数的函数,以及一些不需要使用 curry 的函数。

此外,对于具有冷缓存的应用,这些数字也是需要考虑的。任何减少页面呈现时间的机制都将继续工作。例如,如果您有一个 Varnish 实例正在运行,您的页面可能会继续以相同的速度提供服务。

别忘了

我们将一个非常小的函数与合成和咖喱做了比较。现代 PHP 代码库将使用类来保存业务逻辑和值。让我们使用add函数的以下实现来模拟这一点:

<?php 

class Integer { 
    private $value; 
    public function __construct($v) { $this->value = $v; } 
    public function get() { return $this->value; } 
} 

class Adder { 
    public function add(Integer $a, Integer $b) { 
        return $a->get() + $b->get(); 
    } 
} 

传统方法所需的时间将增加:

<?php 

benchmark([new Adder, 'add'], [new Integer(21), new Integer(33)], 54); 
// mean: 0.767 seconds 
// std:  0.019 seconds 

只需将所有内容包装在一个类中并使用 getter,执行时间几乎翻了一番,这意味着函数方法在基准测试中的速度突然降低了 1.5 倍,而我们的示例应用中的开销现在为 10-15%,这已经好得多了。

我们能做点什么吗?

可悲的是,我们真的没有什么可以自己做的。我们可以通过更有效地实现currycompose方法来节省一点时间,正如我们使用add方法的手动 curryed 版本所演示的那样,但这不会有多大影响。

然而,作为 PHP 核心部分的这两种技术的实现会带来很多好处,可能使它们与传统的功能和方法保持一致,或者真正接近。但是,据我所知,在不久的将来没有这样做的计划。

也可以为 PHP 创建一个 C 语言扩展,以更有效的方式实现这两个函数。然而,这将是不切实际的,因为大多数 PHP 托管公司不允许人们安装自定义扩展。

结束语

正如我们刚才看到的,使用诸如 curry 和函数组合之类的技术会对性能产生影响,这一点很难自行缓解。在我看来,好处大于成本,但重要的是在知情的情况下转向函数式编程。

此外,现在大多数 web 应用在 PHP 应用前面都有某种缓存机制。因此,唯一的代价就是填充此缓存。如果您处于这种情况,我认为没有理由避免使用我们学习的技术。

记忆是一种优化技术,其中存储昂贵函数的结果,以便在任何后续调用中使用相同的参数直接返回。这是数据缓存的一种特殊情况。

尽管它可以用于与任何其他缓存机制具有相同失效问题的非纯函数,但它主要用于所有函数都是纯函数的函数语言中,从而大大简化了它的使用。

其想法是用计算时间换取存储空间。第一次为给定输入调用函数时,将存储结果,下次使用相同参数调用同一函数时,可以立即返回已计算的结果。在 PHP 中,使用函数中的static关键字可以相当容易地实现这一点:

<?php 

function long_computation($n) 
{ 
    static $cache = []; 
    $key = md5(serialize($n)); 

    if(! isset($cache[$key])) { 
        // your computation comes here, the rest is boilerplate 
        sleep(2); 
        $cache[$key] = $n; 
    } 

    return $cache[$key]; 
} 

很明显,有十几种不同的方法可以做类似的事情,但这足够简单,可以了解它是如何工作的。我们还可以想象实现一种过期机制,或者,由于我们使用内存空间而不是计算时间,某种数据结构在不使用值为新结果腾出空间时会被擦除。

另一个选项是将信息存储到磁盘,例如,在同一脚本的多次运行之间保存值。PHP 中至少存在一个库(https://github.com/koktut/php-memoize )就是这样做的。

但是,该库不能很好地处理开箱即用的递归调用,因为函数本身没有修改,因此只会为第一次调用保存值,而不会为递归调用保存值。文章(http://eddmann.com/posts/implementing-and-using-memoization-in-php/ 图书馆自述中链接的更详细地讨论了这个问题,并提出了解决方案。

值得注意的是,Hack有一个属性,该属性将自动记忆具有特定类型(参数的函数的结果 https://docs.hhvm.com/hack/attributes/special#__memoize )。如果您正在使用 Hack 并希望使用注释,我建议您首先阅读Gotchas部分,因为它可能并不总是做您想要的事情。

Hack 是一种在 PHP 之上添加新功能的语言,运行在 Facebook 编写的 PHP 虚拟机上,即HipHop 虚拟机HHVM)。任何 PHP 代码都与 Hack 兼容,但 Hack 添加了一些新语法,使代码与普通 PHP 解释器不兼容。欲了解更多信息,请访问http://hacklang.org /。

哈斯凯尔、斯卡拉和回忆录

Haskell 和 Scala 都不会自动执行记忆。虽然您可以找到多个提供此功能的库,但这两个库的核心都没有这样做的功能。

有一种误解认为 Haskell 在默认情况下会记住所有函数,这是因为该语言是懒惰的。真正发生的是 Haskell 试图尽可能延迟函数调用的计算,一旦延迟,它就会使用 referential transparency 属性用计算值替换其他类似的调用。

然而,在许多情况下,这种替换无法自动进行,除了再次计算值外,别无选择。如果您对这个主题感兴趣,这个堆栈溢出问题是一个很好的开始,在处有所有正确的关键字 http://stackoverflow.com/questions/3951012/when-is-memoization-automatic-in-ghc-haskell

我们将把讨论留在这里,因为这本书是关于 PHP 的。

结束语

这是一个非常快速的回忆录演示,因为这项技术实现起来相当简单,没有什么可说的。我只是想介绍一下,让你知道这个词。

如果您有一些长时间运行的计算,使用相同的参数多次调用,我建议您使用该技术,因为它可以真正加快速度,并且不需要调用方提供任何信息。它的使用非常透明。

但要注意,这不是一颗银弹。根据返回值的数据结构,它会很快占用内存。如果遇到此问题,可以使用某种机制从缓存中清除较旧或使用较少的值。

拥有纯函数的另一个好处是,您可以将计算划分为多个小部分,分配工作负载,并组装结果。对于任何映射、过滤和折叠操作都可以这样做。我们将看到,用于折叠的函数需要是单面的。用于映射和过滤的函数除了纯度之外没有特殊的约束。

除了纯函数之外,映射没有任何特定的约束。假设你有四个核心,或计算机;您只需执行以下步骤:

  1. 将阵列分成四部分。
  2. 将零件发送到每个核心以进行映射。
  3. 合并结果。

在这种特殊情况下,由于合并操作会增加开销,因此它可能比在单个内核上执行要慢。但是,只要计算时间更长,您就可以使用更多的计算能力,从而获得时间。

过滤的操作方式与映射完全相同,只是发送谓词而不是函数。

折叠只能在使用单向操作时发生,因为每次拆分都需要以空值开始,否则可能会扭曲结果:

  1. 将阵列分成四部分。
  2. 将零件发送到每个芯,以空值作为初始值进行折叠。
  3. 将所有结果放入新数组中。
  4. 在新阵列上执行相同的折叠操作。

如果你的收藏真的很大,你可以再次将最终的折叠分成多个部分。

PHP 中的并行任务

PHP 是在计算机只有一个内核的时候创建的,从那时起,使用它的传统方式是使用一个线程来处理每个请求。您可以在 web 服务器中声明多个 worker,以使用不同的进程为不同的请求提供服务,但是一个请求通常只使用一个线程,因此只使用一个核心。

尽管存在 PHP 二进制文件的线程安全版本,但由于上述原因,Linux 发行版通常提供非线程安全版本。这并不意味着不可能在 PHP 中并行化任务,但这肯定会使它变得更加困难。

pthreads 扩展

PHP7 发布了新版本的pthreads扩展,允许您使用新设计的面向对象 API 并行运行多个任务。这真的很棒,如果扩展不可用,甚至有一个polyfill来按顺序执行任务。

术语polyfill起源于 JavaScript 开发。这是一小段代码,用于替换用户浏览器中未实现的功能。有时使用的另一个术语是垫片。在我们的例子中,pthreads polyfill在所有点上都提供了一个 API,该 API 与扩展中的一个类似,但它按顺序运行任务。

遗憾的是,使用扩展是一种挑战。首先,您需要有一个线程安全的 PHP 二进制文件,也称为Zend 线程安全的ZTS二进制文件。正如我们刚才看到的,发行版通常不提供这个版本。据我所知,目前没有官方 PHP 软件包启用 ZTS 的发行版。当试图找到为 Linux 发行版创建自己的 ZTS 二进制文件的说明时,Google 通常很有用。

Windows 和 Mac OS 用户处于更好的位置,因为您可以在上下载 ZTS 二进制文件 http://www.php.net 并且您可以在安装带有homebrew包管理器的 PHP 时启用该选项。

另一个限制是扩展将拒绝在 CGI 上下文中加载。这意味着您只能在命令行上使用它。如果您对 pthreads 扩展的维护人员选择将此约束置于适当位置的原因感兴趣,我建议您阅读他在上写的这篇博文 http://blog.krakjoe.ninja/2015/09/the-worth-of-advice.html

现在,如果我们假设您可以使用 ZTS 版本的 PHP,并且只编写 CLI 应用,那么让我们看看如何使用pthreads扩展执行并行折叠。扩展托管在 GitHub 上的https://github.com/krakjoe/pthreads ,安装说明可在的 PHP 官方文档中找到 http://docs.php.net/manual/en/book.pthreads.php

显然,我们可以通过多种方式使用线程实现折叠。我们将尝试使用通用方法。在某些情况下,更专业的版本可能会更快,但这应该已经涵盖了整个用例范围:

<?php 

class Folder extends Thread { 
    private $collection; 
    private $callable; 
    private $initial; 

    private $results; 

    private function __construct($callable, $collection, $initial) 
    { 
        $this->callable = $callable; 
        $this->collection = $collection; 
        $this->initial = $initial; 
    } 

    public function run() 
    { 
        $this->results = array_reduce($this->collection, $this- >callable, $this->initial); 
    } 

    public static function fold($callable, array $collection,  $initial, $threads=4) 
    { 
        $chunks = array_chunk($collection, ceil(count($collection) / $threads)); 

        $threads = array_map(function($i) use ($chunks, $callable,  $initial) { 
            $t = new static($callable, $chunks[$i], $initial); 
            $t->start(); 
            return $t; 
        }, range(0, $threads - 1)); 

        $results = array_map(function(Thread $t) { 
            $t->join(); 
            return $t->results; 
        }, $threads); 

        return array_reduce($results, $callable, $initial); 
    } 
} 

实现非常简单;我们有一个简单的Thread执行每个区块的缩减,并在最后使用一个简单的array_reduce函数将它们组合起来。我们本可以选择使用Pool实例来管理各种线程,但在这种简单的情况下,它会使实现复杂化。

另一种可能是递归,直到生成的数组最多包含$threads个元素;这样的话,我们就可以使用我们所拥有的全部计算能力,直到最后。但同样,这会使实施变得复杂。

你如何使用它?只需调用静态方法:

<?php 

$add = function($a, $b) { 
    return $a + $b; 
}; 

$collection = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; 

echo Folder::fold($add, $collection, 0); 
// 55 

如果你想玩弄一下这个想法,一个小库以并行方式实现所有三个高阶函数(https://github.com/functional-php/parallel )。您可以使用 composer 安装它:

composer require functional-php/parallel

消息队列

PHP 中并行化任务的另一个选项是使用消息传递队列。消息队列提供异步通信协议。您将拥有一个服务器,该服务器将保存消息,直到一个或多个客户端检索它们。

我们可以通过让应用向服务器发送 X 条消息来实现并行计算,每个分布式任务发送一条消息。然后,一定数量的工作者将检索消息并执行计算,并将结果作为新消息发送回应用。

您可以使用许多不同的消息队列实现。通常,队列本身不是用 PHP 实现的,但是大多数队列都有一个客户端实现,您可以使用它。我们将使用RabbitMQphp amqplib客户端。

解释如何安装服务器超出了本书的范围,但您在 Internet 上有很多教程。我们也不会解释关于实现的所有细节,只解释与我们的主题相关的内容。您可以使用 composer 安装 PHP 库:

composer require php-amqplib/php-amqplib

我们需要为我们的工作人员和应用提供一个实现。我们先创建一个包含公共部分的文件,我们称之为09-rabbitmq.php

<?php 

require_once './vendor/autoload.php'; 
use PhpAmqpLib\Connection\AMQPStreamConnection; 

$connection = new AMQPStreamConnection('localhost', 5672, 'guest',  'guest'); 
$channel = $connection->channel(); 
list($queue, ,) = $channel->queue_declare($queue_name, false,  false, false, false); 

$fold_function = function($a, $b) { 
    return $a + $b; 
}; 

现在我们创建 worker:

<?php 
use PhpAmqpLib\Message\AMQPMessage; 

$queue_name = 'fold_queue'; 
require_once('09-rabbitmq.php'); 

function callback($r) { 
    global $fold_function; 

    $data = unserialize($r->body); 

    $result = array_reduce($data['collection'], $fold_function,  $data['initial']); 

    $msg = new AMQPMessage(serialize($result)); 

    $r->delivery_info['channel']->basic_publish($msg, '', $r- >get('reply_to')); 
    $r->delivery_info['channel']->basic_ack($r- >delivery_info['delivery_tag']); 
}; 

$channel->basic_qos(null, 1, null); 
$channel->basic_consume('fold_queue', '', false, false, false,  false, 'callback'); 

while(count($channel->callbacks)) { 
    $channel->wait(); 
} 

$channel->close(); 
$connection->close(); 

现在我们创建应用本身:

<?php 
use PhpAmqpLib\Message\AMQPMessage; 

$queue_name = ''; 
require_once('09-rabbitmq.php'); 

function send($channel, $queue, $chunk, $initial) 
{ 
    $data = [ 
        'collection' => $chunk, 
        'initial' => $initial 
    ]; 
    $msg = new AMQPMessage(serialize($data), array('reply_to' =>  $queue)); 
    $channel->basic_publish($msg, '', 'fold_queue'); 
} 

class Results { 
    private $results = []; 
    private $channel; 

    public function register($channel, $queue) 
    { 
        $this->channel = $channel; 
        $channel->basic_consume($queue, '', false, false, false,  false, [$this, 'process']); 
    } 

    public function process($rep) 
    { 
        $this->results[] = unserialize($rep->body); 
    } 

    public function get($expected) 
    { 
        while(count($this->results) < $expected) { 
            $this->channel->wait(); 
        } 

        return $this->results; 
    } 
} 

$results = new Results(); 
$results->register($channel, $queue); 

$initial = 0; 

send($channel, $queue, [1, 2, 3], 0); 
send($channel, $queue, [4, 5, 6], 0); 
send($channel, $queue, [7, 8, 9], 0); 
send($channel, $queue, [10], 0); 

echo array_reduce($results->get(4), $fold_function, $initial); 
// 55 

显然,这是一个非常幼稚的实现。要求这样的文件是不好的做法,因为至少 PHP5 是这样,而且代码非常脆弱,但它的目的是演示消息队列提供的可能性。

启动 worker 时,它将自己注册为fold_queue队列的使用者。当接收到消息时,它使用在数据的公共部分中声明的折叠函数,并将结果发送回定义为应答的队列。循环确保我们等待传入消息;给定代码,工作者永远不应该自己退出。

应用有一个send函数,可以在fold_queue队列上发送消息。Results类实例将自身注册为默认队列的使用者,以便接收每个工作者的结果。然后发送四条消息,我们要求Results实例等待它们。最后,对接收到的数据进行压缩,得到最终结果。

如果只启动一个 worker,结果将按顺序发送;但是,如果启动多个 worker,每个 worker 都将从 RabbitMQ 服务器检索消息并对其进行处理,从而启用并行化。

与使用线程相比,消息队列有多个好处:

  • 工人可以在多台计算机上工作
  • 工人可以用任何其他语言实现,为所选队列提供客户端
  • 队列服务器提供冗余和故障切换机制
  • 队列服务器可以在工作者之间执行负载平衡

如果您计划只将工作负载分布在一台唯一计算机的核心上,那么在可用时使用 pthreads 库可能会更容易一些,但是如果您希望具有更大的灵活性,消息队列是一种选择。

其他选择

在 PHP 中,还有其他方法可以开始并行计算,但通常它们会使检索值比我们刚才看到的更困难。

一个选项是使用curl_multi_exec函数异步执行多个 HTTP 请求。一般结构类似于我们在消息队列示例中使用的结构。然而,与一个完整的消息传递系统的全部功能相比,这种可能性也是有限的。

您还可以使用多个相关函数之一创建其他 PHP 进程。在这种情况下,困难往往是在不丢失数据的情况下传递和检索数据,因为这样做的方式取决于与环境相关的许多因素。如果你想这样做,popenexecpassthru函数可能是你最好的选择。

如果你不想做所有繁重的工作,你也可以使用Parallel.php库,它将大部分复杂性抽象出来。您可以使用 composer 安装它:

composer require kzykhys/parallel

该文档可在 GitHub 的上获得 https://github.com/kzykhys/Parallel.php 。由于库使用 Unix 套接字,与数据丢失相关的大多数问题都消失了。但是,您将无法在 Windows 上使用它。

结束语

正如我们所看到的,在 PHP 中使用多个线程或进程可能不是最容易的事情,尤其是在网页上下文中。然而,这是可以实现的,它可以大大加快长时间的计算。

随着 pthreads for PHP7 的重写,我们希望更多的 Linux 发行版和托管公司将开始提供 ZTS 版本。

如果是这样的话,并行计算在 PHP 中开始成为现实,那么不必求助于Hadoop framework等其他语言的外部库,就可以进行一些简单的大数据处理。

最后,我想简单介绍一下消息队列。即使您没有以功能性的方式使用它们来处理数据并返回结果,它们也是在 web 请求上下文中执行长时间操作的一种很好的方式。例如,如果您为用户提供了一种上传一组图像的方法,并且您需要对它们进行处理,那么您可以将操作排队,并立即返回给用户。排队的消息将在适当的时间得到处理,您的用户无需等待。

在本章中,我们遗憾地发现在进行函数式编程时需要付出代价。由于 PHP 不支持诸如 curry 和函数组合之类的功能,因此在使用包装函数时会产生与包装函数相关的开销。在某些情况下,这显然是一个问题,但缓存通常可以降低这一成本。

我们讨论了 memorization,一种缓存技术,它与纯函数相结合,允许您加速对给定函数的后续调用,而不必使存储的结果无效。

最后,我们讨论了 PHP 中的并行计算,它利用了一个事实,即对集合执行的任何纯操作都可以分布在多个节点上,而不必担心共享状态。

下一章将专门介绍使用框架的开发人员,因为我们将发现如何在 PHP 世界中目前使用的最常见框架的上下文中利用我们迄今为止学到的技术。

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

从0开始学大数据 -〔李智慧〕

Serverless入门课 -〔蒲松洋(秦粤)〕

软件设计之美 -〔郑晔〕

Web安全攻防实战 -〔王昊天〕

说透5G -〔杨四昌〕

说透元宇宙 -〔方军〕

Web 3.0入局攻略 -〔郭大治〕

深入浅出可观测性 -〔翁一磊〕

Vue 3 企业级项目实战课 -〔杨文坚〕