阅读更多

3顶
1踩

编程语言
【编者按】Node.js的大红大紫也造就了一大批新应用、新工具的诞生。比如基于Node.js的开发框架、开源软件等等。本文转自阿里巴巴用户体验部有一点博客,作者详细描述了使用Node.js、Node-Webkitk开发的实时多人游戏框架Spaceroom过程。

在 Node.js 如火如荼发展的今天,我们已经可以用它来做各种各样的事情。前段时间UP主参加了极客松活动,在这次活动中我们意在做出一款让“低头族”能够更多交流的游戏,核心功能便是 Lan Party 概念的实时多人互动。极客松比赛只有短得可怜的36个小时,要求一切都敏捷迅速。在这样的前提下初期的准备显得有些“水到渠成”。跨平台应用的 solution 我们选择了node-webkit,它足够简单且符合我们的要求。

按照需求,我们的开发可以按照模块分开进行。本文具体讲述了开发 Spaceroom(我们的实时多人游戏框架)的过程,包括一系列的探索与尝试,以及对 Node.js、WebKit 平台本身的一些限制的解决,和解决方案的提出。

Getting started

Spaceroom 一瞥

在最开始,Spaceroom 的设计肯定是需求驱动的。我们希望这个框架可以提供以下的基础功能:

能够以 房间(或者说频道) 为单位,区分一组用户
能够接收收集组内用户发来的指令
在各个客户端之间对时,能够按照规定的 interval 精确广播游戏数据
能够尽量消除由网络延迟带来的影响
当然,在 coding 的后期,我们为 Spaceroom 提供了更多的功能,包括暂停游戏、在各个客户端之间生成一致的随机数等(当然根据需求这些都可以在游戏逻辑框架里自己实现,并非一定需要用到 Spaceroom 这个更多在通信层面上工作的框架)。

APIs

Spaceroom 分为前后端两个部分。服务器端所需要做的工作包括维护房间列表,提供创建房间、加入房间的功能。我们的客户端 APIs 看起来像这样:


  • spaceroom.connect(address, callback) – 连接服务器
  • spaceroom.createRoom(callback) – 创建一个房间
  • spaceroom.joinRoom(roomId) – 加入一个房间
  • spaceroom.on(event, callback) – 监听事件
  • ……

客户端连接到服务器后,会收到各种各样的事件。例如一个在一间房间中的用户,可能收到新玩家加入的事件,或者游戏开始的事件。我们给客户端赋予了“生命周期”,他在任何时候都会处于以下状态的一种:



你可以通过 spaceroom.state 获取客户端的当前状态。

使用服务器端的框架相对来说简单很多,如果使用默认的配置文件,那么直接运行服务器端框架就可以了。我们有一个基本的需求:服务器代码 可以直接运行在客户端中,而不需要一个单独的服务器。玩过 PS 或者 PSP 的玩家应该清楚我在说什么。当然,可以跑在专门的服务器里,自然也是极好的。

逻辑代码的实现这里简略了。初代的 Spaceroom 完成了一个 Socket 服务器的功能,它维护房间列表,包括房间的状态,以及每一个房间对应的游戏时通信(指令收集,bucket 广播等)。具体实现可以参看源码。

同步算法

那么,要怎么才能使得各个客户端之间显示的东西都是实时一致的呢?

这个东西听起来很有意思。仔细想想,我们需要服务器帮我们传递什么东西?自然就会想到是什么可能造成各个客户端之间逻辑的不一致:用户指令。既然处理游戏逻辑的代码都是相同的,那么给定同样的条件,代码的运行结果也是相同的。唯一不同的就是在游戏过程当中,接收到的各种玩家指令。理所当然的,我们需要一种方式来同步这些指令。如果所有的客户端都能拿到同样的指令,那么所有的客户端从理论上讲就能有一样的运行结果了。

网络游戏的同步算法千奇百怪,适用的场景也各不相同。Spaceroom 采用的同步算法类似于帧锁定的概念。我们把时间轴分成了一个一个的区间,每一个区间称为一个 bucket。Bucket 是用来装载指令的,由服务器端维护。在每一个 bucket 时间段的末尾,服务器把 bucket 广播给所有客户端,客户端拿到 bucket 之后从中取出指令,验证之后执行。

为了降低网络延迟造成的影响,服务器接到的来自客户端的指令每一个都会按照一定的算法投递到对应的 bucket 中,具体按照以下步骤:

设 order_start 为指令携带的指令发生时间, t 为 order_start 所在 bucket 的起始时间
如果 t + delay_time <= 当前正在收集指令的 bucket 的起始时间,将指令投递到 当前正在收集指令的 bucket 中,否则继续 step 3
将指令投递到 t + delay_time 对应的 bucket 中
其中 delay_time 为约定的服务器延迟时间,可以取为客户端之间的平均延迟,Spaceroom 里默认取值80,以及 bucket 长度默认取值48. 在每个 bucket 时间段的末尾,服务器将此 bucket 广播给所有客户端,并开始接收下一个 bucket 的指令。客户端根据收到的 bucket 间隔,在逻辑中自动进行对时,将时间误差控制在一个可以接受的范围内。

这个意思是,正常情况下,客户端每隔 48ms 会收到从服务器端发来的一个 bucket,当到达需要处理这个 bucket 的时间时,客户端会进行相应处理。假设客户端 FPS=60,每隔 3帧 左右的时间,会收到一次 bucket,根据这个 bucket 来更新逻辑。如果因为网络波动,超出时间后还没有收到 bucket,客户端暂停游戏逻辑并等待。在一个 bucket 之内的时间,逻辑的更新可以使用 lerp 的方法。



在 delay_time = 80, bucket_size = 48 的情况下,任一指令最少会被延迟 96ms 执行。更改这两个参数,例如在 delay_time = 60, bucket_size = 32 的情况下,任一指令最少会被延迟 64ms 执行。

计时器引发的血案

整个看下来,我们的框架在运行的时候需要有一个精确的计时器。在固定的 interval 下执行 bucket 的广播。理所当然地,我们首先想到了使用setInterval(),然而下一秒我们就意识到这个想法有多么的不靠谱:调皮的setInterval() 似乎有非常严重的误差。而且要命的是,每一次的误差都会累计起来,造成越来越严重的后果。

于是我们马上又想到了使用 setTimeout(),通过动态地修正下一次到时的时间来让我们的逻辑大致稳定在规定的 interval 左右。例如此次setTimeout()比预期少了5ms, 那么我们下一次就让他提前5ms. 不过测试结果不尽人意,而且这怎么看都不够优雅。

所以我们又要换一个思路。是否可以让 setTimeout() 尽可能快地到期,然后我们检查当前的时间是否到达目标时间。例如在我们的循环中,使用setTimeout(callback, 1) 来不停地检查时间,这看起来像是一个不错的主意。

令人失望的计时器

我们立即写了一段代码来测试我们的想法,结果令人失望。在目前最新的node.js 稳定版(v0.10.32)以及 Windows 平台下,运行这样一段代码:
var sum = 0, count = 0;  
function test() {  
  var now = Date.now();  
  setTimeout(function () {  
    var diff = Date.now() - now;  
    sum += diff;  
    count++;  
    test();  
  });  
}  
  
test();  

一段时间之后在控制台里输入 sum/count,可以看到一个结果,类似于:
> sum / count  
15.624555160142348  

什么?!!我要 1ms 的间隔时间,你却告诉我实际的平均间隔为 15.625ms!这个画面简直是太美。我们在 mac 上做同样的测试,得到的结果是 1.4ms。于是我们心生疑惑:这到底是什么鬼?如果我是一个果粉,我可能就要得出 Windows 太垃圾然后放弃 Windows 的结论了,不过好在我是一名严谨的前端工程师,于是我开始继续思索起这个数字来。

等等,这个数字为什么那么眼熟?15.625ms 这个数字会不会太像 Windows 下的最大计时器间隔了?立即下载了一个 ClockRes 进行测试,控制台一跑果然得到了如下结果:
Maximum timer interval: 15.625 ms  
Minimum timer interval: 0.500 ms  
Current timer interval: 1.001 ms

果不其然!查阅 node.js 的手册我们能看到这样一段对 setTimeout 的描述:
The actual delay depends on external factors like OS timer granularity and system load.

然而测试结果显示,这个实际延迟是最大计时器间隔(注意此时系统的当前计时器间隔只有 1.001ms),无论如何让人无法接受,强大的好奇心驱使我们翻翻看 node.js 的源码来一窥究竟。
Node.js 中的 BUG

相信大部分你我都对 Node.js 的 even loop 机制有一定的了解,查看 timer 实现的源码我们可以大致了解到 timer 的实现原理,让我们从 event loop 的主循环讲起:
while (r != 0 && loop->stop_flag == 0) {  
    /* 更新全局时间 */  
    uv_update_time(loop);  
    /* 检查计时器是否到期,并执行对应计时器回调 */  
    uv_process_timers(loop);  
  
    /* Call idle callbacks if nothing to do. */  
    if (loop->pending_reqs_tail == NULL &&  
        loop->endgame_handles == NULL) {  
      /* 防止event loop退出 */  
      uv_idle_invoke(loop);  
    }  
  
    uv_process_reqs(loop);  
    uv_process_endgames(loop);  
  
    uv_prepare_invoke(loop);  
  
    /* 收集 IO 事件 */  
    (*poll)(loop, loop->idle_handles == NULL &&  
                  loop->pending_reqs_tail == NULL &&  
                  loop->endgame_handles == NULL &&  
                  !loop->stop_flag &&  
                  (loop->active_handles > 0 ||  
                   !ngx_queue_empty(&loop->active_reqs)) &&  
                  !(mode & UV_RUN_NOWAIT));  
    /* setImmediate() 等 */  
    uv_check_invoke(loop);  
    r = uv__loop_alive(loop);  
    if (mode & (UV_RUN_ONCE | UV_RUN_NOWAIT))  
      break;  
  }  

其中 uv_update_time 函数的源码如下:
https://github.com/joyent/libuv/blob/v0.10/src/win/timer.c)
void uv_update_time(uv_loop_t* loop) {  
  /* 获取当前系统时间 */  
  DWORD ticks = GetTickCount();  
  
  /* The assumption is made that LARGE_INTEGER.QuadPart has the same type */  
  /* loop->time, which happens to be. Is there any way to assert this? */  
  LARGE_INTEGER* time = (LARGE_INTEGER*) &loop->time;  
  
  /* If the timer has wrapped, add 1 to it's high-order dword. */  
  /* uv_poll must make sure that the timer can never overflow more than */  
  /* once between two subsequent uv_update_time calls. */  
  if (ticks < time->LowPart) {  
    time->HighPart += 1;  
  }  
  time->LowPart = ticks;  
}  

该函数的内部实现,使用了 Windows 的 GetTickCount() 函数来设置当前时间。简单地来说,在调用setTimeout 函数之后,经过一系列的挣扎,内部的 timer->due 会被设置为当前 loop 的时间 + timeout。在 event loop 中,先通过 uv_update_time 更新当前 loop 的时间,然后在uv_process_timers 中检查是否有计时器到期,如果有就进入 JavaScript 的世界。通篇读下来,event loop大概是这样一个流程:


    [1]更新全局时间
    [2]检查定时器,如果有定时器过期,执行回调
    [3]检查 reqs 队列,执行正在等待的请求
    [4]进入 poll 函数,收集 IO 事件,如果有 IO 事件到来,将相应的处理函数添加到 reqs 队列中,以便在下一次 event loop 中执行。在 poll 函数内部,调用了一个系统方法来收集 IO 事件。这个方法会使得进程阻塞,直到有 IO 事件到来或者到达设定好的超时时间。调用这个方法时,超时时间设定为最近的一个 timer 到期的时间。意思就是阻塞收集 IO 事件,最大阻塞时间为 下一个 timer 的到底时间。

Windows下 poll 函数之一的源码
static void uv_poll(uv_loop_t* loop, int block) {  
  DWORD bytes, timeout;  
  ULONG_PTR key;  
  OVERLAPPED* overlapped;  
  uv_req_t* req;  
  
  if (block) {  
    /* 取出最近的一个计时器的过期时间 */  
    timeout = uv_get_poll_timeout(loop);  
  } else {  
    timeout = 0;  
  }  
  
  GetQueuedCompletionStatus(loop->iocp,  
                            &bytes,  
                            &key,  
                            &overlapped,  
                            /* 最多阻塞到下个计时器到期 */  
                            timeout);  
  
  if (overlapped) {  
    /* Package was dequeued */  
    req = uv_overlapped_to_req(overlapped);  
    /* 把 IO 事件插入队列里 */  
    uv_insert_pending_req(loop, req);  
  } else if (GetLastError() != WAIT_TIMEOUT) {  
    /* Serious error */  
    uv_fatal_error(GetLastError(), "GetQueuedCompletionStatus");  
  }  
}  

按照上述步骤,假设我们设置了一个 timeout = 1ms 的计时器,poll 函数会最多阻塞 1ms 之后恢复(如果期间没有任何 IO 事件)。在继续进入 event loop 循环的时候, uv_update_time 就会更新时间,然后uv_process_timers 发现我们的计时器到期,执行回调。所以初步的分析是,要么是uv_update_time 出了问题(没有正确地更新当前时间),要么是 poll 函数等待 1ms 之后恢复,这个 1ms 的等待出了问题。

查阅 MSDN,我们惊人地发现对 GetTickCount 函数的描述:
The resolution of the GetTickCount function is limited to the resolution of the system timer, which is typically in the range of 10 milliseconds to 16 milliseconds.
GetTickCount 的精度是如此的粗糙!假设 poll 函数正确地阻塞了 1ms 的时间,然而下一次执行uv_update_time 的时候并没有正确地更新当前 loop 的时间!所以我们的定时器没有被判定为过期,于是 poll 又等待了 1ms,又进入了下一次 event loop。直到终于 GetTickCount 正确地更新了(所谓15.625ms更新一次),loop 的当前时间被更新,我们的计时器才在 uv_process_timers 里被判定过期。

向 WebKit 求 助

Node.js 的这段源码看得人很无助:他使用了一个精度低下的时间函数,而且没有做任何处理。不过我们立刻想到了既然我们使用 Node-WebKit,那么除了 Node.js 的 setTimeout,我们还有 Chromium 的 setTimeout。写一段测试代码,用我们的浏览器或者 Node-WebKit 跑一下:http://marks.lrednight.com/test.html#1 (#后面跟的数字表示需要测定的间隔),结果如下图:



按照 HTML5 的规范,理论结果应该是前5次结果是1ms,以后的结果是4ms。测试用例中显示的结果是从第3次开始的,也就是说表上的数据理论上应该是前3次都是1ms,之后的结果都是4ms。结果有一定的误差,而且根据规定,我们能拿到的最小的理论结果是4ms。虽然我们不满足,但显然这比 node.js 的结果让我们满意多了。强大的好奇心趋势我们看看 Chromium 的源码,看看他是如何实现的。(https://chromium.googlesource.com/chromium/src.git/+/38.0.2125.101/base/time/time_win.cc)

首先,在确定 loop 的当前时间方面,Chromium 使用了 timeGetTime() 函数。查阅 MSDN 可以发现这个函数的精度受系统当前 timer interval 影响。在我们的测试机上,理论上也就是上文中提到过的 1.001ms。然而 Windows 系统默认情况下,timer interval 是其最大值(测试机上也就是 15.625ms),除非应用程序修改了全局 timer interval。

如果你关注 IT界的新闻,你一定看过这样的一条新闻。看起来我们的 Chromium 把计时器间隔设定得很小了嘛!看来我们不用担心系统计时器间隔的问题了?不要开心得太早,这样的一条修复给了我们当头一棒。事实上,这个问题在 Chrome 38 中已经得到了修复。难道我们要使用修复以前的 Node-WebKit?这显然不够优雅,而且阻止了我们使用性能更高的 Chromium 版本。

进一步查看 Chromium 源码我们可以发现,在有计时器,且计时器的 timeout < 32ms 时,Chromium 会更改系统的全局定时器间隔以实现小于 15.625ms 精度的计时器。([url=]查看源码[/url]) 启动计时器时,一个叫HighResolutionTimerManager 的东西会被启用,这个类会根据当前设备的电源类型,调用EnableHighResolutionTimer 函数。具体来说,当前设备用电池时,他会调用EnableHighResolutionTimer(false),而使用电源时会传入 true。EnableHighResolutionTimer 函数的实现如下:
1.void Time::EnableHighResolutionTimer(bool enable) {  
2.  base::AutoLock lock(g_high_res_lock.Get());  
3.  if (g_high_res_timer_enabled == enable)  
4.    return;  
5.  g_high_res_timer_enabled = enable;  
6.  if (!g_high_res_timer_count)  
7.    return;  
8.  // Since g_high_res_timer_count != 0, an ActivateHighResolutionTimer(true)   
9.  // was called which called timeBeginPeriod with g_high_res_timer_enabled   
10.  // with a value which is the opposite of |enable|. With that information we   
11.  // call timeEndPeriod with the same value used in timeBeginPeriod and   
12.  // therefore undo the period effect.   
13.  if (enable) {  
14.    timeEndPeriod(kMinTimerIntervalLowResMs);  
15.    timeBeginPeriod(kMinTimerIntervalHighResMs);  
16.  } else {  
17.    timeEndPeriod(kMinTimerIntervalHighResMs);  
18.    timeBeginPeriod(kMinTimerIntervalLowResMs);  
19.  }  
20.}  

其中,kMinTimerIntervalLowResMs = 4,kMinTimerIntervalHighResMs = 1。timeBeginPeriod 以及timeEndPeriod 是 Windows 提供的用来修改系统 timer interval 的函数。也就是说在接电源时,我们能拿到的最小的 timer interval 是1ms,而使用电池时,是4ms。由于我们的循环不断地调用了 setTimeout,根据 W3C 规范,最小的间隔也是 4ms,所以松口气,这个对我们的影响不大。
又一个精度问题

回到开头,我们发现测试结果显示,setTimeout 的间隔并不是稳定在 4ms 的,而是在不断地波动。而http://marks.lrednight.com/test.html#48 测试结果也显示,间隔在 48ms 和 49ms 之间跳动。原因是,在 Chromium 和 Node.js 的 event loop 中,等待 IO 事件的那个 Windows 函数调用的精度,受当前系统的计时器影响。游戏逻辑的实现需要用到 requestAnimationFrame 函数(不停更新画布),这个函数可以帮我们将计时器间隔至少设置为 kMinTimerIntervalLowResMs(因为他需要一个16ms的计时器,触发了高精度计时器的要求)。测试机使用电源的时候,系统的 timer interval 是 1ms,所以测试结果有 ±1ms 的误差。如果你的电脑没有被更改系统计时器间隔,运行上面那个#48的测试,max可能会到达48+16=64ms。

使用 Chromium 的 setTimeout 实现,我们可以将 setTimeout(fn, 1) 的误差控制在 4ms 左右,而 setTimeout(fn, 48) 的误差可以控制在 1ms 左右。于是,我们的心中有了一幅新的蓝图,它让我们的代码看起来像是这样:
/* Get the max interval deviation */  
var deviation = getMaxIntervalDeviation(bucketSize); // bucketSsize = 48, deviation = 2;  
function gameLoop() {  
  var now = Date.now();  
  if (previousBucket + bucketSize <= now) {  
    previousBucket = now;  
  
    doLogic();  
  }  
  
  if (previousBucket + bucketSize - Date.now() > deviation) {  
    // Wait 46ms. The actual delay is less than 48ms.  
    setTimeout(gameLoop, bucketSize - deviation);  
  } else {  
    // Busy waiting. Use setImmediate instead of process.nextTick because the former does not block IO events.  
    setImmediate(gameLoop);  
  }  
}  

上面的代码让我们等待一个误差小于 bucket_size( bucket_size – deviation) 的时间而不是直接等于一个 bucket_size,46ms 的 delay 即便发生了最大的误差,根据上文的理论,实际间隔也是小于48ms的。剩下的时间我们使用忙等待的方法,确保我们的 gameLoop 在足够精确的 interval 下执行。

虽然我们利用 Chromium 在一定程度上解决了问题,但这显然不够优雅。

还记得我们最初的要求吗?我们的服务器端代码是应该可以脱离 Node-Webkit 客户端的,直接在一台有 Node.js 环境的电脑中运行。如果直接跑上面的代码,deviation 的值至少是16ms,也就是说在每一个48ms中,我们要忙等待16ms的时间。CPU使用率蹭蹭蹭就上去了。

意想不到的惊喜

真是气人啊,Node.js 里这么大的一个BUG,没有人注意到吗?答案真是让我们喜出望外。这个BUG在 v.0.11.3 版本里已经得到了修复。直接查看 libuv 代码的 master 分支也能看到修改后的结果。具体的做法是,在 poll 函数等待完成之后,把 loop 的当前时间,加上一个 timeout。这样即便 GetTickCount 没有反应过来,在经过poll的等待之后,我们还是加上了这段等待的时间。于是计时器就能够顺利地到期了。

也就是说,辛苦半天的问题,在 v.0.11.3 里已经得到了解决。不过,我们的努力不是白费的。因为即便消除了 GetTickCount 函数的影响,poll 函数本身也受到系统定时器的影响。解决方案之一,便是编写 Node.js 插件,更改系统定时器的间隔。

不过我们这次的游戏,初步设定是没有服务器的。客户端建立房间之后,就成为了一个服务器。服务器代码可以跑在 Node-WebKit 的环境中,所以 Windows 系统下计时器的问题的优先级并不是最高的。按照上文中我们给出的解决方案,结果已经足够让我们满意。

收尾

解决了计时器的问题,我们的框架实现也就基本上再没什么阻碍了。我们提供了 WebSocket 的支持(在纯 HTML5 环境下),也自定义了通信协议实现了性能更高的 Socket 支持(Node-WebKit 环境下)。当然,Spaceroom 的功能在最初是比较简陋的,但随着需求的提出和时间的增加,我们也在逐渐地完善这个框架。

例如我们发现在我们的游戏里需要生成一致的随机数的时候,我们就为 Spaceroom 加上了这样的功能。在游戏开始的时候 Spaceroom 会分发随机数种子,客户端的 Spaceroom 提供了利用 md5 的随机性,借助随机数种子生成随机数的方法。

看起来还是蛮欣慰的。在编写这样一个框架的过程当中,也学到了很多的东西。如果你对 Spaceroom 有点兴趣,也可以参与到它当中来。相信,Spaceroom 会在更多的地方施展它的拳脚。
  • 大小: 16.3 KB
  • 大小: 9 KB
  • 大小: 9.1 KB
3
1
评论 共 3 条 请登录后发表评论
3 楼 zhunengfei 2014-11-04 12:52
楼主能否提供项目地址?深度学习下
2 楼 yeah_nihao 2014-10-31 00:14
mark!
1 楼 zbm2001 2014-10-29 15:28
好文!楼主能否提供项目地址?深度学习下

发表评论

您还没有登录,请您登录后再发表评论

相关推荐

  • html家电分类,电器有哪些种类?家用电器都有哪些类型?

    现在生活条件在不断的变好人们家庭条件也越来越好。在生活中每个家庭中到处都能够看到家电的存在。给生活提供了很多的方便。家用电器的种类和品牌也是多,在购买之前做个简单的了解还是挺有必要的。接下来,就来看看...

  • 双十一数码好物推荐排行榜,值得入手的数码好物分享

    与平常按摩器不一样的是,西屋UX5采用了分体式设计,可以根据不同部位采用不同选材方案,机身带有记忆海绵填充,两侧偏软,可以柔软托脸,舒适贴合前颈,后颈偏硬,强力环绕,加强支撑。耳机成为生活上不可或缺的一...

  • 家用计算机有辐射吗,哪些家用电器有辐射

    随着人们对健康意识的提高,具有辐射的电器也开始收大家关注,那么我们身边的家用电器到底有没有辐射呢?下面学习啦小编分享了常用家电辐射排行榜,一起来了解吧。常用家电辐射排行榜1、超低频家电(额定50赫兹)对应产品...

  • 【干货|知识分享】Solidworks与Rhino有什么区别呢?

    【干货|知识分享】Solidworks与Rhino有什么区别呢? 三维建模软件有很多,其中常用、功能相似的就是Solidworks和Rhino,这两个对于大学生们最为熟悉的、最开始接触的三维建模软件了。 这里就说明一下 建模思路有何...

  • python父亲节礼物送什么_父亲节送什么礼物好

    所以我写这篇文章的目的是想推荐一下父亲节送什么好以及不建议送什么。以下这些东西建议不买按摩椅这类东西比较贵,学生党可以不用考虑了。还有如果经济条件允许的,也要考虑家里的地方是不是能够放得下。手环这个...

  • 全麦吐司和普通吐司的区别_全麦面包吐司和全麦面包片有什么区别?

    家庭烤面包的做法2006-12-23 14:39:17大中小原料:1 取一些面粉过筛,再取等量的...2 确认三次发好后,把它放在案板上做成面包丕,也可以根据个人的喜好包入馅料。然后把做好的面包丕密封后放在温暖处醒发二十分钟...

  • 开关电源PCB设计中的走线技巧

    简介:文章主要是讨论和分析开关电源印制板布线原则、开关电源印制板铜皮走线的一些事项、开关电源印制板大电流走线的处理以及反激电源反射电压...电源中的应用、多层印制板在开关电源电路中的应用的一些大家关注的问题...

  • 收藏 | 电子元器件图片、名称、符号图形对照

    之前分享过一篇接口大全常用电子接口大全,遇到不认识的,就翻出来对照辨认!(点击阅读)这次看到了一篇电子元器件实物图片大全,如果遇到不确认的电子元器件,就翻出来对照辨认下,下面分享给大家:一...

  • 【行业专题报告】家电、白电、黑电-专题资料

    今天为大家分享专题研究报告目录如下 : ————————————————————— (一) 家电-专题资料-2021月度新增50份(8.9-9.12) 目录. 2021家电行业中期策略报告:新消费框架,从攻城圈地到城池堡垒-...

  • dvd清洗碟效果好吗,cd清洗碟原理

    修复普通划伤的光盘笔者在网上看了一些介绍如何修复光盘的文章,也按照文章上所说的步骤对光盘进行了修复,...但还有一些光盘依然效果很差,笔者仔细观察这些光盘的数据面已经基本看不到划痕,但为什么读起来还是...

  • 为什么总显示连接服务器失败怎么回事,为什么总是出现"与总服务器连接失败"的字样 – 手机爱问...

    2005-06-17求求大家帮忙!!!发一个洗衣除渍的窍门给大家看看1、红墨水渍:新渍先用冷水洗,再用温肥皂液浸泡一会儿,再用清水漂洗;陈渍可先用洗涤剂洗,再用10%的酒精溶液搓擦即可祛除。2、墨渍:可用饭粒和洗涤...

  • 笔记本辐射与日常电器辐射对比

     台式机之所以背面辐射大是因为正面有金属档板等等厂商设置的防护措施,但是背面却没有;笔记本电脑不同,笔记本电脑辐射最大的地方在键盘上方,因此,合上盖子之后,相对而言辐射反而小些。      下面给你个全...

  • 计算机一级有哪些快捷键,计算机一级Photoshop快捷键大全

    计算机一级Photoshop快捷键大全引导语:想要加快做题效率当然少不了快捷键,以下是百分网小编分享给大家的的计算机一级Photoshop快捷键,欢迎阅读记忆!1、需要多层选择时,可以先用选择工具选定文件中的区域,屏幕会...

  • 介绍给大家400种生活小窍门(很有用的)

    介绍给大家400种生活小窍门 转自:mop.com 1 、生活窍门:巧用牙膏6:若有小面积皮肤损伤或烧伤、烫伤,抹上少许牙膏,可立即止血止痛,也可防止感染,疗效颇佳。2 、生

  • 央视《家有妙招》整理版,共250招

     方法:将半碗大粒盐(即粗盐,超市有售,2元一袋)和脏了的毛绒玩具一起放入一个塑料袋,系口,用力摇晃几十下即可.取出时盐已因吸附了污垢而变成灰黑色.  优点:避免了因水洗造成玩具毛绒打结.同时盐还有消毒效果.快捷...

  • mysql enum 建表_MySQL 枚举类型如何定义比较好 tinyint?enum?varchar?

    据我观察大家还是用tinyint的比较多,少数也会直接用varchar。说到枚举,这个类型真的是有点坑,写的时候又不想校验(尤其是枚举值比较多的时候),又想直接在表里可以显示出原字符(说到底还是懒)。tinyint可以校验插入...

  • 没有基础的人如何自学裁缝?

    郝蕴涵 ,有专栏 逗比裁缝成长史 微信号Double-we 收录于 编辑推荐 •maggie 等 2836 人赞同 之前好多妹子私信我别的裙子的方法,答应过有空就上(好吧,其实我也不知道是不是都是妹子)。发现如果继续...

  • 央视《家有妙招》整理版,值得永远收藏!

    央视《家有妙招》整理版,值得永远收藏! 2010年10月26日 星期二 下午 3:56  1,不用水,毛绒玩具巧清洁  方法:将半碗大粒盐(即粗盐,超市有售,2元一袋)和脏了的毛绒玩具一起放入一个塑料袋,系口,用力...

  • nodejs-x64-0.10.21.tgz

    Node.js,简称Node,是一个开源且跨平台的JavaScript运行时环境,它允许在浏览器外运行JavaScript代码。Node.js于2009年由Ryan Dahl创立,旨在创建高性能的Web服务器和网络应用程序。它基于Google Chrome的V8 JavaScript引擎,可以在Windows、Linux、Unix、Mac OS X等操作系统上运行。 Node.js的特点之一是事件驱动和非阻塞I/O模型,这使得它非常适合处理大量并发连接,从而在构建实时应用程序如在线游戏、聊天应用以及实时通讯服务时表现卓越。此外,Node.js使用了模块化的架构,通过npm(Node package manager,Node包管理器),社区成员可以共享和复用代码,极大地促进了Node.js生态系统的发展和扩张。 Node.js不仅用于服务器端开发。随着技术的发展,它也被用于构建工具链、开发桌面应用程序、物联网设备等。Node.js能够处理文件系统、操作数据库、处理网络请求等,因此,开发者可以用JavaScript编写全栈应用程序,这一点大大提高了开发效率和便捷性。 在实践中,许多大型企业和组织已经采用Node.js作为其Web应用程序的开发平台,如Netflix、PayPal和Walmart等。它们利用Node.js提高了应用性能,简化了开发流程,并且能更快地响应市场需求。

  • node-v4.1.1-linux-armv6l.tar.xz

    Node.js,简称Node,是一个开源且跨平台的JavaScript运行时环境,它允许在浏览器外运行JavaScript代码。Node.js于2009年由Ryan Dahl创立,旨在创建高性能的Web服务器和网络应用程序。它基于Google Chrome的V8 JavaScript引擎,可以在Windows、Linux、Unix、Mac OS X等操作系统上运行。 Node.js的特点之一是事件驱动和非阻塞I/O模型,这使得它非常适合处理大量并发连接,从而在构建实时应用程序如在线游戏、聊天应用以及实时通讯服务时表现卓越。此外,Node.js使用了模块化的架构,通过npm(Node package manager,Node包管理器),社区成员可以共享和复用代码,极大地促进了Node.js生态系统的发展和扩张。 Node.js不仅用于服务器端开发。随着技术的发展,它也被用于构建工具链、开发桌面应用程序、物联网设备等。Node.js能够处理文件系统、操作数据库、处理网络请求等,因此,开发者可以用JavaScript编写全栈应用程序,这一点大大提高了开发效率和便捷性。 在实践中,许多大型企业和组织已经采用Node.js作为其Web应用程序的开发平台,如Netflix、PayPal和Walmart等。它们利用Node.js提高了应用性能,简化了开发流程,并且能更快地响应市场需求。

Global site tag (gtag.js) - Google Analytics