Tomcat源码分析——Session管理分析(上)
前言
对于广大java开发者而已,对于J2EE规范中的Session应该并不陌生,我们可以使用Session管理用户的会话信息,最常见的就是拿Session用来存放用户登录、身份、权限及状态等信息。对于使用Tomcat作为Web容器的大部分开发人员而言,Tomcat是如何实现Session标记用户和管理Session信息的呢?
概述
Session
Tomcat内部定义了Session和HttpSession这两个会话相关的接口,其类继承体系如图1所示。
图1 Session类继承体系
图1中额外列出了Session的类继承体系,这里对他们逐个进行介绍。
Session:Tomcat中有关会话的基本接口规范,图1列出了它定义的主要方法,表1对这些方法进行介绍。
表1 Session接口说明
方法 | 描述 |
getCreationTime()/setCreationTime(time : long) | 获取与设置Session的创建时间 |
getId()/setId(id : String) | 获取与设置Session的ID |
getThisAccessedTime() | 获取最近一次请求的开始时间 |
getLastAccessedTime() | 获取最近一次请求的完成时间 |
getManager()/setManager(manager : Manager) | 获取与设置Session管理器 |
getMaxInactiveInterval()/setMaxInactiveInterval(interval : int) | 获取与设置Session的最大访问间隔 |
getSession() | 获取HttpSession |
isValid()/setValid(isValid : boolean) | 获取与设置Session的有效状态 |
access()/endAccess() | 开始与结束Session的访问 |
expire() | 设置Session过期 |
HttpSession:在HTTP客户端与HTTP服务端提供的一种会话的接口规范,图1列出了它定义的主要方法,表2对这些方法进行介绍。
表2 HttpSession接口说明
方法 | 描述 |
getCreationTime() | 获取Session的创建时间 |
getId() | 获取Session的ID |
getLastAccessedTime() | 获取最近一次请求的完成时间 |
getServletContext() | 获取当前Session所属的ServletContext |
getMaxInactiveInterval()/setMaxInactiveInterval(interval : int) | 获取与设置Session的最大访问间隔 |
getAttribute(name : String) /setAttribute(name : String, value : Object) | 获取与设置Session作用域的属性 |
removeAttribute(name : String) | 清除Session作用域的属性 |
invalidate() | 使Session失效并解除任何与此Session绑定的对象 |
ClusterSession:集群部署下的会话接口规范,图1列出了它的主要方法,表3对这些方法进行介绍。
表3 ClusterSession接口说明
方法 | 描述 |
isPrimarySession() | 是否是集群的主Session |
setPrimarySession(boolean primarySession) | 设置集群主Session |
StandardSession:标准的HTTP Session实现,本文将以此实现为例展开。
在部署Tomcat集群时,需要使集群中各个节点的会话状态保持同步,目前Tomcat提供了两种同步策略:
- ReplicatedSession:每次都把整个会话对象同步给集群中的其他节点,其他节点然后更新整个会话对象。这种实现比较简单方便,但会造成大量无效信息的传输。
- DeltaSession:对会话中增量修改的属性进行同步。这种方式由于是增量的,所以会大大降低网络I/O的开销,但是实现上会比较复杂因为涉及到对会话属性操作过程的管理。
Session管理器
Tomcat内部定义了Manager接口用于制定Session管理器的接口规范,目前已经有很多Session管理器的实现,如图2所示。
图2 Session管理器的类继承体系
对应图2中的内容我们下面逐个描述:
Manager:Tomcat对于Session管理器定义的接口规范,图2已经列出了Manager接口中定义的主要方法,表4详细描述了这些方法的作用。
表4 Manager接口说明
方法 | 描述 |
getContainer()/setContainer(container : Container) | 获取或设置Session管理器关联的容器,一般为Context容器 |
getDistributable()/setDistributable(distributable : boolean) | 获取或设置Session管理器是否支持分布式 |
getMaxInactiveInterval()/setMaxInactiveInterval(interval : int) | 获取或设置Session管理器创建的Session的最大非活动时间间隔 |
getSessionIdLength()/setSessionIdLength(idLength : int) | 获取或设置Session管理器创建的Session ID的长度 |
getSessionCounter()/setSessionCounter(sessionCounter : long) | 获取或设置Session管理器创建的Session总数 |
getMaxActive()/setMaxActive(maxActive : int) | 获取或设置当前已激活Session的最大数量 |
getActiveSessions() | 获取当前激活的所有Session |
getExpiredSessions()/setExpiredSessions(expiredSessions : long) | 获取或设置当前已过期Session的数量 |
getRejectedSessions()/setRejectedSessions(rejectedSessions : int) | 获取或设置已拒绝创建Session的数量 |
getSessionMaxAliveTime()/setSessionMaxAliveTime(sessionMaxAliveTime : int) | 获取或设置已过期Session中的最大活动时长 |
getSessionAverageAliveTime()/setSessionAverageAliveTime(sessionAverageAliveTime : int) | 获取或设置已过期Session的平均活动时长 |
add(session : Session)/remove(session : Session) | 给Session管理器增加或删除活动Session |
changeSessionId(session : Session) | 给Session设置新生成的随机Session ID |
createSession(sessionId : String) | 基于Session管理器的默认属性配置创建新的Session |
findSession(id : String) | 返回sessionId参数唯一标记的Session |
findSessions() | 返回Session管理器管理的所有活动Session |
load()/unload() | 从持久化机制中加载Session或向持久化机制写入Session |
backgroundProcess() | 容器接口中定义的为具体容器在后台处理相关工作的实现,Session管理器基于此机制实现了过期Session的销毁 |
ManagerBase:封装了Manager接口通用实现的抽象类,未提供对load()/unload()等方法的实现,需要具体子类去实现。所有的Session管理器都继承自ManagerBase。
ClusterManager:在Manager接口的基础上增加了集群部署下的一些接口,所有实现集群下Session管理的管理器都需要实现此接口。
PersistentManagerBase:提供了对于Session持久化的基本实现。
PersistentManager:继承自PersistentManagerBase,可以在Server.xml的<Context>元素下通过配置<Store>元素来使用。PersistentManager可以将内存中的Session信息备份到文件或数据库中。当备份一个Session对象时,该Session对象会被复制到存储器(文件或者数据库)中,而原对象仍然留在内存中。因此即便服务器宕机,仍然可以从存储器中获取活动的Session对象。如果活动的Session对象超过了上限值或者Session对象闲置了的时间过长,那么Session会被换出到存储器中以节省内存空间。
StandardManager:不用配置<Store>元素,当Tomcat正常关闭,重启或Web应用重新加载时,它会将内存中的Session序列化到Tomcat目录下的/work/Catalina/host_name/webapp_name/SESSIONS.ser文件中。当Tomcat重启或应用加载完成后,Tomcat会将文件中的Session重新还原到内存中。如果突然终止该服务器,则所有Session都将丢失,因为StandardManager没有机会实现存盘处理。
ClusterManagerBase:提供了对于Session的集群管理实现。
DeltaManager:继承自ClusterManagerBase。此Session管理器是Tomcat在集群部署下的默认管理器,当集群中的某一节点生成或修改Session后,DeltaManager将会把这些修改增量复制到其他节点。
BackupManager:没有继承ClusterManagerBase,而是直接实现了ClusterManager接口。是Tomcat在集群部署下的可选的Session管理器,集群中的所有Session都被全量复制到一个备份节点。集群中的所有节点都可以访问此备份节点,达到Session在集群下的备份效果。
为简单起见,本文以StandardManager为例讲解Session的管理。StandardManager是StandardContext的子组件,用来管理当前Context的所有Session的创建和维护。如果你已经阅读或者熟悉了《Tomcat源码分析——生命周期管理》一文的内容,那么你就知道当StandardContext正式启动,也就是StandardContext的startInternal方法(见代码清单1)被调用时,StandardContext还会启动StandardManager。
代码清单1
@Override
protected synchronized void startInternal() throws LifecycleException { // 省略与Session管理无关的代码 // Acquire clustered manager
Manager contextManager = null;
if (manager == null) {
if ( (getCluster() != null) && distributable) {
try {
contextManager = getCluster().createManager(getName());
} catch (Exception ex) {
log.error("standardContext.clusterFail", ex);
ok = false;
}
} else {
contextManager = new StandardManager();
}
} // Configure default manager if none was specified
if (contextManager != null) {
setManager(contextManager);
} if (manager!=null && (getCluster() != null) && distributable) {
//let the cluster know that there is a context that is distributable
//and that it has its own manager
getCluster().registerManager(manager);
}
// 省略与Session管理无关的代码 try {
// Start manager
if ((manager != null) && (manager instanceof Lifecycle)) {
((Lifecycle) getManager()).start();
} // Start ContainerBackgroundProcessor thread
super.threadStart();
} catch(Exception e) {
log.error("Error manager.start()", e);
ok = false;
} // 省略与Session管理无关的代码
}
从代码清单1可以看到StandardContext的startInternal方法中涉及Session管理的执行步骤如下:
- 创建StandardManager;
- 如果Tomcat结合Apache做了分布式部署,会将当前StandardManager注册到集群中;
- 启动StandardManager;
StandardManager的start方法用于启动StandardManager,实现见代码清单2。
代码清单2
@Override
public synchronized final void start() throws LifecycleException { //省略状态校验的代码if (state.equals(LifecycleState.NEW)) {
init();
} else if (!state.equals(LifecycleState.INITIALIZED) &&
!state.equals(LifecycleState.STOPPED)) {
invalidTransition(Lifecycle.BEFORE_START_EVENT);
} setState(LifecycleState.STARTING_PREP); try {
startInternal();
} catch (LifecycleException e) {
setState(LifecycleState.FAILED);
throw e;
} if (state.equals(LifecycleState.FAILED) ||
state.equals(LifecycleState.MUST_STOP)) {
stop();
} else {
// Shouldn't be necessary but acts as a check that sub-classes are
// doing what they are supposed to.
if (!state.equals(LifecycleState.STARTING)) {
invalidTransition(Lifecycle.AFTER_START_EVENT);
} setState(LifecycleState.STARTED);
}
}
从代码清单2可以看出启动StandardManager的步骤如下:
- 调用init方法初始化StandardManager;
- 调用startInternal方法启动StandardManager;
StandardManager的初始化
经过上面的分析,我们知道启动StandardManager的第一步就是调用父类LifecycleBase的init方法,关于此方法已在《Tomcat源码分析——生命周期管理》一文详细介绍,所以我们只需要关心StandardManager的initInternal。StandardManager本身并没有实现initInternal方法,但是StandardManager的父类ManagerBase实现了此方法,其实现见代码清单3。
代码清单3
@Override
protected void initInternal() throws LifecycleException { super.initInternal(); setDistributable(((Context) getContainer()).getDistributable()); // Initialize random number generation
getRandomBytes(new byte[16]);
}
阅读代码清单3,我们总结下ManagerBase的initInternal方法的执行步骤:
- 将容器自身即StandardManager注册到JMX(LifecycleMBeanBase的initInternal方法的实现请参考《Tomcat源码分析——生命周期管理》一文);
- 从父容器StandardContext中获取当前Tomcat是否是集群部署,并设置为ManagerBase的布尔属性distributable;
- 调用getRandomBytes方法从随机数文件/dev/urandom中获取随机数字节数组,如果不存在此文件则通过反射生成java.security.SecureRandom的实例,用它生成随机数字节数组。
注意:此处调用getRandomBytes方法生成的随机数字节数组并不会被使用,之所以在这里调用实际是为了完成对随机数生成器的初始化,以便将来分配Session ID时使用。
我们详细阅读下getRandomBytes方法的代码实现,见代码清单4。
代码清单4
protected void getRandomBytes(byte bytes[]) {
// Generate a byte array containing a session identifier
if (devRandomSource != null && randomIS == null) {
setRandomFile(devRandomSource);
}
if (randomIS != null) {
try {
int len = randomIS.read(bytes);
if (len == bytes.length) {
return;
}
if(log.isDebugEnabled())
log.debug("Got " + len + " " + bytes.length );
} catch (Exception ex) {
// Ignore
}
devRandomSource = null; try {
randomIS.close();
} catch (Exception e) {
log.warn("Failed to close randomIS.");
} randomIS = null;
}
getRandom().nextBytes(bytes);
}
代码清单4中的setRandomFile方法(见代码清单5)用于从随机数文件/dev/urandom中获取随机数字节数组。
代码清单5
public void setRandomFile( String s ) {
// as a hack, you can use a static file - and generate the same
// session ids ( good for strange debugging )
if (Globals.IS_SECURITY_ENABLED){
randomIS = AccessController.doPrivileged(new PrivilegedSetRandomFile(s));
} else {
try{
devRandomSource=s;
File f=new File( devRandomSource );
if( ! f.exists() ) return;
randomIS= new DataInputStream( new FileInputStream(f));
randomIS.readLong();
if( log.isDebugEnabled() )
log.debug( "Opening " + devRandomSource );
} catch( IOException ex ) {
log.warn("Error reading " + devRandomSource, ex);
if (randomIS != null) {
try {
randomIS.close();
} catch (Exception e) {
log.warn("Failed to close randomIS.");
}
}
devRandomSource = null;
randomIS=null;
}
}
}
代码清单4中的getRandom方法(见代码清单6)通过反射生成java.security.SecureRandom的实例,并用此实例生成随机数字节数组。
代码清单6
public Random getRandom() {
if (this.random == null) {
// Calculate the new random number generator seed
long seed = System.currentTimeMillis();
long t1 = seed;
char entropy[] = getEntropy().toCharArray();
for (int i = 0; i < entropy.length; i++) {
long update = ((byte) entropy[i]) << ((i % 8) * 8);
seed ^= update;
}
try {
// Construct and seed a new random number generator
Class<?> clazz = Class.forName(randomClass);
this.random = (Random) clazz.newInstance();
this.random.setSeed(seed);
} catch (Exception e) {
// Fall back to the simple case
log.error(sm.getString("managerBase.random", randomClass),
e);
this.random = new java.util.Random();
this.random.setSeed(seed);
}
if(log.isDebugEnabled()) {
long t2=System.currentTimeMillis();
if( (t2-t1) > 100 )
log.debug(sm.getString("managerBase.seeding", randomClass) + " " + (t2-t1));
}
} return (this.random); }
根据以上的分析,StandardManager的初始化主要就是执行了ManagerBase的initInternal方法。
StandardManager的启动
调用StandardManager的startInternal方法用于启动StandardManager,见代码清单7。
代码清单7
@Override
protected synchronized void startInternal() throws LifecycleException { // Force initialization of the random number generator
if (log.isDebugEnabled())
log.debug("Force random number initialization starting");
generateSessionId();
if (log.isDebugEnabled())
log.debug("Force random number initialization completed"); // Load unloaded sessions, if any
try {
load();
} catch (Throwable t) {
log.error(sm.getString("standardManager.managerLoad"), t);
} setState(LifecycleState.STARTING);
}
从代码清单7可以看出启动StandardManager的步骤如下:
步骤一 调用generateSessionId方法(见代码清单8)强制初始化随机数生成器;
注意:此处调用generateSessionId方法的目的不是为了生成Session ID,而是为了强制初始化随机数生成器。
代码清单8
protected synchronized String generateSessionId() { byte random[] = new byte[16];
String jvmRoute = getJvmRoute();
String result = null; // Render the result as a String of hexadecimal digits
StringBuilder buffer = new StringBuilder();
do {
int resultLenBytes = 0;
if (result != null) {
buffer = new StringBuilder();
duplicates++;
} while (resultLenBytes < this.sessionIdLength) {
getRandomBytes(random);
random = getDigest().digest(random);
for (int j = 0;
j < random.length && resultLenBytes < this.sessionIdLength;
j++) {
byte b1 = (byte) ((random[j] & 0xf0) >> 4);
byte b2 = (byte) (random[j] & 0x0f);
if (b1 < 10)
buffer.append((char) ('0' + b1));
else
buffer.append((char) ('A' + (b1 - 10)));
if (b2 < 10)
buffer.append((char) ('0' + b2));
else
buffer.append((char) ('A' + (b2 - 10)));
resultLenBytes++;
}
}
if (jvmRoute != null) {
buffer.append('.').append(jvmRoute);
}
result = buffer.toString();
} while (sessions.containsKey(result));
return (result); }
步骤二 加载持久化的Session信息。为什么Session需要持久化?由于在StandardManager中,所有的Session都维护在一个ConcurrentHashMap中,因此服务器重启或者宕机会造成这些Session信息丢失或失效,为了解决这个问题,Tomcat将这些Session通过持久化的方式来保证不会丢失。下面我们来看看StandardManager的load方法的实现,见代码清单9所示。
代码清单9
public void load() throws ClassNotFoundException, IOException {
if (SecurityUtil.isPackageProtectionEnabled()){
try{
AccessController.doPrivileged( new PrivilegedDoLoad() );
} catch (PrivilegedActionException ex){
Exception exception = ex.getException();
if (exception instanceof ClassNotFoundException){
throw (ClassNotFoundException)exception;
} else if (exception instanceof IOException){
throw (IOException)exception;
}
if (log.isDebugEnabled())
log.debug("Unreported exception in load() "
+ exception);
}
} else {
doLoad();
}
}
如果需要安全机制是打开的并且包保护模式打开,会通过创建PrivilegedDoLoad来加载持久化的Session,其实现如代码清单10所示。
代码清单10
private class PrivilegedDoLoad
implements PrivilegedExceptionAction<Void> { PrivilegedDoLoad() {
// NOOP
} public Void run() throws Exception{
doLoad();
return null;
}
}
从代码清单10看到实际负责加载的方法是doLoad,根据代码清单9知道默认情况下,加载Session信息的方法也是doLoad。所以我们只需要看看doLoad的实现了,见代码清单11。
代码清单11
protected void doLoad() throws ClassNotFoundException, IOException {
if (log.isDebugEnabled())
log.debug("Start: Loading persisted sessions"); // Initialize our internal data structures
sessions.clear(); // Open an input stream to the specified pathname, if any
File file = file();
if (file == null)
return;
if (log.isDebugEnabled())
log.debug(sm.getString("standardManager.loading", pathname));
FileInputStream fis = null;
BufferedInputStream bis = null;
ObjectInputStream ois = null;
Loader loader = null;
ClassLoader classLoader = null;
try {
fis = new FileInputStream(file.getAbsolutePath());
bis = new BufferedInputStream(fis);
if (container != null)
loader = container.getLoader();
if (loader != null)
classLoader = loader.getClassLoader();
if (classLoader != null) {
if (log.isDebugEnabled())
log.debug("Creating custom object input stream for class loader ");
ois = new CustomObjectInputStream(bis, classLoader);
} else {
if (log.isDebugEnabled())
log.debug("Creating standard object input stream");
ois = new ObjectInputStream(bis);
}
} catch (FileNotFoundException e) {
if (log.isDebugEnabled())
log.debug("No persisted data file found");
return;
} catch (IOException e) {
log.error(sm.getString("standardManager.loading.ioe", e), e);
if (fis != null) {
try {
fis.close();
} catch (IOException f) {
// Ignore
}
}
if (bis != null) {
try {
bis.close();
} catch (IOException f) {
// Ignore
}
}
throw e;
} // Load the previously unloaded active sessions
synchronized (sessions) {
try {
Integer count = (Integer) ois.readObject();
int n = count.intValue();
if (log.isDebugEnabled())
log.debug("Loading " + n + " persisted sessions");
for (int i = 0; i < n; i++) {
StandardSession session = getNewSession();
session.readObjectData(ois);
session.setManager(this);
sessions.put(session.getIdInternal(), session);
session.activate();
if (!session.isValidInternal()) {
// If session is already invalid,
// expire session to prevent memory leak.
session.setValid(true);
session.expire();
}
sessionCounter++;
}
} catch (ClassNotFoundException e) {
log.error(sm.getString("standardManager.loading.cnfe", e), e);
try {
ois.close();
} catch (IOException f) {
// Ignore
}
throw e;
} catch (IOException e) {
log.error(sm.getString("standardManager.loading.ioe", e), e);
try {
ois.close();
} catch (IOException f) {
// Ignore
}
throw e;
} finally {
// Close the input stream
try {
ois.close();
} catch (IOException f) {
// ignored
} // Delete the persistent storage file
if (file.exists() )
file.delete();
}
} if (log.isDebugEnabled())
log.debug("Finish: Loading persisted sessions");
}
从代码清单11看到StandardManager的doLoad方法的执行步骤如下:
- 清空sessions缓存维护的Session信息;
- 调用file方法返回当前Context下的Session持久化文件,比如:D:\workspace\Tomcat7.0\work\Catalina\localhost\host-manager\SESSIONS.ser;
- 打开Session持久化文件的输入流,并封装为CustomObjectInputStream;
- 从Session持久化文件读入持久化的Session的数量,然后逐个读取Session信息并放入sessions缓存中。
至此,有关StandardManager的启动就介绍到这里,我将会在《TOMCAT源码分析——SESSION管理分析(下)》一文讲解Session的分配、追踪、销毁等内容。
如需转载,请标明本文作者及出处——作者:jiaan.gja,本文原创首发:博客园,原文链接:http://www.cnblogs.com/jiaan-geng/p/4913616.html
Tomcat源码分析——Session管理分析(上)的更多相关文章
- TOMCAT8源码分析——SESSION管理分析(上)
前言 对于广大java开发者而已,对于J2EE规范中的Session应该并不陌生,我们可以使用Session管理用户的会话信息,最常见的就是拿Session用来存放用户登录.身份.权限及状态等信息.对 ...
- Tomcat源码分析——Session管理分析(下)
前言 在<TOMCAT源码分析——SESSION管理分析(上)>一文中我介绍了Session.Session管理器,还以StandardManager为例介绍了Session管理器的初始化 ...
- tomcat 源码分析
Tomcat源码分析——Session管理分析(下) Tomcat源码分析——Session管理分析(上) Tomcat源码分析——请求原理分析(下) Tomcat源码分析——请 ...
- eclipse导入tomcat源码
我的开发环境:windows7 64位 一.官网下载tomcat源码.在此奉上一站地址:http://archive.apache.org/dist/tomcat/: 二.编译源码生成.jar文件: ...
- Tomcat源码分析——请求原理分析(上)
前言 谈起Tomcat的诞生,最早可以追溯到1995年.近20年来,Tomcat始终是使用最广泛的Web服务器,由于其使用Java语言开发,所以广为Java程序员所熟悉.很多人早期的J2EE项目,由程 ...
- Tomcat源码分析
前言: 本文是我阅读了TOMCAT源码后的一些心得. 主要是讲解TOMCAT的系统框架, 以及启动流程.若有错漏之处,敬请批评指教! 建议: 毕竟TOMCAT的框架还是比较复杂的, 单是从文字上理解, ...
- Tomcat源码分析--转
一.架构 下面谈谈我对Tomcat架构的理解 总体架构: 1.面向组件架构 2.基于JMX 3.事件侦听 1)面向组件架构 tomcat代码看似很庞大,但从结构上看却很清晰和简单,它主要由一堆组件组成 ...
- TOMCAT源码分析(转)
前言: 本文是我阅读了TOMCAT源码后的一些心得. 主要是讲解TOMCAT的系统框架, 以及启动流程.若有错漏之处,敬请批评指教!建议: 毕竟TOMCAT的框架还是比较复杂的, 单是从文字上 ...
- Tomcat源码分析——请求原理分析(下)
前言 本文继续讲解TOMCAT的请求原理分析,建议朋友们阅读本文时首先阅读过<TOMCAT源码分析——请求原理分析(上)>和<TOMCAT源码分析——请求原理分析(中)>.在& ...
随机推荐
- mysql5.7 column cannot be null
背景 独立测试环境安装了数据库,但安装的版本是mysql 5.7的版本,而研发用的是mysql5.6的版本,在执行某个数据库操作的提示,提示column “xxxx”cannot be null 问题 ...
- xshell显示隐藏窗口页签
有时候不知道操作说了什么红框中的页签会消失,可以ctrl+shift+t 控制显示隐藏
- C#之工厂
工厂在我看来分为三种分别都是简单工厂,工厂方法,和抽象工厂,这三种都是将使用和创建分开的一种模式 接下来我来介绍一下我理解的简单工厂模式: 在平时我们需要使用生产对象的一个类当我们需要new 一个对象 ...
- django drf django-filter的method过滤
1.View Demo from django.shortcuts import render from rest_framework.views import APIView from rest_f ...
- 域名通过infopath访问webservice出现401错误
解决办法: 跟服务器有关,需要再每台服务器进行以下配置 New-ItemProperty HKLM:\System\CurrentControlSet\Control\Lsa -Name " ...
- Android 获取 content layout
if (findViewById(android.R.id.content) instanceof ViewGroup) { ViewGroup mainView = ((ViewGroup)find ...
- CCF CSP 201803-1 跳一跳
题目链接:http://118.190.20.162/view.page?gpid=T73 问题描述 近来,跳一跳这款小游戏风靡全国,受到不少玩家的喜爱. 简化后的跳一跳规则如下:玩家每次从当前方块跳 ...
- [JS] 理解jquery的$.extend()、$.fn和$.fn.extend()
jQuery为开发插件提拱了两个方法,分别是: jQuery.fn.extend(); jQuery.extend(); jQuery.fn jQuery.fn = jQuery.prototype ...
- [转] 红帽7搭建Zabbix监控
zabbix是一个基于WEB界面的提供分布式系统监视以及网络监视功能的企业级的开源解决方案. zabbix能监视各种网络参数,保证服务器系统的安全运营:并提供灵活的通知机制以让系统管理员快速定位/解决 ...
- 我编写 33 个 VSCode 扩展的原因以及管理扩展的经验
简评:使用工具的同时自己创造一些工具或扩展,是一件很棒的事情. 以下"我"指原作者 Fabio 大家好,我叫 Fabio,是一位自学成才的开发人员,热衷于开源和授权.我也喜欢自己制 ...