LDAP学习笔记
我们可以通过JNDI的API来操作LDAP
LDAP v3
国际化
通过国际字符集(ISO 10646)处理国际化,以表示字符串形式的协议元素(例如DN)
LDAP v3与 LDAP v2版本的区别之一:使用UTF-8对字符串编码
认证方式
LDAP的不同版本支持不同类型的身份验证。
LDAP v2支持匿名,简单(明文密码)和Kerberos v4身份验证。
LDAP v3支持匿名,简单和SASL身份验证。
SASL是简单身份验证和安全层(RFC 2222)。它指定了质询-响应协议,在该协议中,为了进行身份验证和建立安全层以在其上进行后续通信,在客户端和服务器之间交换数据。通过使用SASL,LDAP可以支持LDAP客户端和服务器同意的任何类型的身份验证。
当前定义了几种SASL机制:DIGEST-MD5, CRAM-MD5, Anonymous, External, S/Key, GSSAPI, 和 Kerberos v4。 LDAP v3客户端可以使用任何这些SASL机制,只要LDAP v3服务器支持它们即可。此外,可以使用新的(尚待定义)SASL机制,而不必对LDAP进行更改。
发送 LDAP 请求而不执行"绑定"的客户端将被视为匿名客户端
通过使用Context.SECURITY_AUTHENTICATION环境属性指定身份验证机制。该属性可以具有以下值之一。
属性 | 描述 |
sasl_mech | 用空格分隔的SASL机制名称列表。使用列出的SASL机制之一(例如,“ CRAM-MD5”表示使用RFC 2195中描述的CRAM-MD5 SASL机制)。 |
none | 不使用身份验证(匿名) |
simple | 使用弱认证(明文密码) |
如果客户端未指定任何身份验证环境属性,则默认身份验证机制为“none”。然后,该客户端将被视为匿名客户端。
如果客户端指定身份验证信息而未显式指定Context.SECURITY_AUTHENTICATION属性,则默认身份验证机制为“simple”。
匿名认证(none)
如前所述,如果未设置身份验证环境属性,则默认身份验证机制为“none”。如果客户端将Context.SECURITY_AUTHENTICATION环境属性设置为“ none”,则身份验证机制为“ none”,并且所有其他身份验证环境属性都将被忽略。您只需要明确地执行此操作,以确保可以忽略任何可能已设置的其他身份验证属性。无论哪种情况,客户端都将被视为匿名客户端。这意味着服务器不知道或不在乎客户端是谁,并且将允许客户端访问(读取和更新)已配置为可由任何未经身份验证的客户端访问的任何数据。
这是一个将Context.SECURITY_AUTHENTICATION属性显式设置为“ none”的示例(即使这样做不是严格必要的,因为这是默认设置)。
// Set up the environment for creating the initial context
Hashtable<String, Object> env = new Hashtable<String, Object>();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
env.put(Context.PROVIDER_URL, "ldap://localhost:389/o=JNDITutorial");
// Use anonymous authentication
env.put(Context.SECURITY_AUTHENTICATION, "none");
// Create the initial context
DirContext ctx = new InitialDirContext(env);
// ... do something useful with ctx
明文密码认证(simple)
simple身份验证包括向LDAP服务器发送客户端(用户)的标准DN和客户端的明文密码(请参阅RFC 2251和RFC 2829)。由于可以从网络读取密码,因此该机制存在安全问题。为了避免以这种方式公开密码,您可以在加密通道(例如SSL)中使用simple的身份验证机制,前提是LDAP服务器支持该机制。
要使用simple的身份验证机制,必须按如下所示设置三个身份验证环境属性。
-
Context.SECURITY_AUTHENTICATION 属性值设置为"simple"
-
Context.SECURITY_PRINCIPAL 设置为要认证的实体的标准DN(例如,“ cn = S.User,ou = NewHires,o = JNDITutorial”)。它的类型为java.lang.String。
-
Context.SECURITY_CREDENTIALS 设置为主体的密码(例如“ mysecret”)。它的类型为java.lang.String,char数组(char [])或字节数组(byte [])。如果密码是java.lang.String或char数组,则对LDAP v3使用UTF-8进行编码,对于LDAP v2使用ISO-Latin-1进行编码,以将其传输到服务器。如果密码是byte [],则将密码原样发送到服务器。
Note:
如果您为Context.SECURITY_CREDENTIALS环境属性提供一个空字符串,一个空字节/字符数组或null,那么身份验证机制将为“none”。这是因为LDAP要求密码对于简单身份验证为非空。如果未提供密码,则协议会自动将身份验证转换为“none”。
SASL认证
LDAP v3协议使用SASL支持可插入身份验证。这意味着LDAP客户端和服务器可以配置为协商和使用可能的非标准和/或自定义机制进行身份验证,具体取决于客户端和服务器所需的保护级别。 LDAP v2协议不支持SASL。
当前定义了几种SASL机制:
- Anonymous ( RFC 2245)
- CRAM-MD5 ( RFC 2195 )
- Digest-MD5 ( RFC 2831)
- External ( RFC 2222)
- Kerberos V4 ( RFC 2222)
- Kerberos V5 ( RFC 2222)
- SecurID ( RFC 2808)
- S/Key ( RFC 2222)
在上一个列表中的机制中,流行的LDAP服务器(例如来自Oracle,OpenLDAP和Microsoft的那些)支持 External, Digest-MD5, 和 Kerberos V5。 RFC 2829建议使用Digest-MD5作为LDAP v3服务器的强制性默认机制。
这是一个简单的程序,用于查找LDAP服务器支持的SASL机制的列表:
// Create initial context
DirContext ctx = new InitialDirContext();
// Read supportedSASLMechanisms from root DSE
Attributes attrs = ctx.getAttributes(
"ldap://localhost:389", new String[]{"supportedSASLMechanisms"});
这是通过在支持 External SASL机制的服务器上运行该程序所产生的输出。
{supportedsaslmechanisms=supportedSASLMechanisms:
EXTERNAL, GSSAPI, DIGEST-MD5}
若要使用特定的SASL机制,请在Context.SECURITY_AUTHENTICATION环境属性中指定其Internet分配号码授权机构(IANA)注册的机制名称。您还可以指定LDAP提供程序尝试的机制列表。这是通过指定以空格分隔的机制名称的有序列表来完成的。 LDAP提供程序将使用它找到实现的第一种机制。
这是一个示例,要求LDAP提供程序尝试获取DIGEST-MD5机制的实现,如果该实现不可用,则将其用于GSSAPI。
env.put(Context.SECURITY_AUTHENTICATION, "DIGEST-MD5 GSSAPI");
您可能会从应用程序用户那里获得身份验证机制的列表。或者,您可以通过类似于前面显示的呼叫来询问LDAP服务器来获取它。 LDAP提供程序本身不咨询服务器以获取此信息。它只是尝试查找和使用指定机制的实现。
平台中的LDAP提供程序具有对External, Digest-MD5, 和GSSAPI (Kerberos v5) SASL机制的内置支持。您可以添加对其他机制的支持。
后续的DIGEST-MD5和自定义认证方式就不翻译了~~
使用JNDI向LDAP进行身份验证
在JNDI中,认证信息在环境属性中指定。使用InitialDirContext类(或其超类或子类)创建初始上下文时,将提供一组环境属性,其中一些可能包含身份验证信息。你可以使用以下环境属性来指定认证信息。
- Context.SECURITY_AUTHENTICATION ("java.naming.security.authentication") 指定要使用的身份验证机制。对于JDK中的LDAP服务提供者,它可以是以下字符串之一:“ none”,“ simple”,sasl_mech,其中sasl_mech是用空格分隔的SASL机制名称列表。
- Context.SECURITY_PRINCIPAL ("java.naming.security.principal") 指定执行身份验证的用户/程序的名称,并取决于Context.SECURITY_AUTHENTICATION属性的值。
- Context.SECURITY_CREDENTIALS ("java.naming.security.credentials") 指定执行身份验证的用户/程序的凭据,并取决于Context.SECURITY_AUTHENTICATION属性的值。
创建初始上下文后,底层LDAP服务提供者从这些环境属性中提取身份验证信息,并使用LDAP“绑定”操作将它们传递给服务器。
// Set up the environment for creating the initial context
Hashtable<String, Object> env = new Hashtable<String, Object>();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
env.put(Context.PROVIDER_URL, "ldap://localhost:389/o=JNDITutorial");
// Authenticate as S. User and password "mysecret"
env.put(Context.SECURITY_AUTHENTICATION, "simple");
env.put(Context.SECURITY_PRINCIPAL,
"cn=S. User, ou=NewHires, o=JNDITutorial");
env.put(Context.SECURITY_CREDENTIALS, "mysecret");
// Create the initial context
DirContext ctx = new InitialDirContext(env);
// ... do something useful with ctx
如果要对现有上下文使用不同的身份验证信息,则可以使用Context.addToEnvironment()和Context.removeFromEnvironment()来更新包含身份验证信息的环境属性。上下文中方法的后续调用将使用新的身份验证信息与服务器进行通信。
下面的示例显示创建上下文后,上下文的身份验证信息如何更改为“none”。
// Authenticate as S. User and the password "mysecret"
env.put(Context.SECURITY_AUTHENTICATION, "simple");
env.put(Context.SECURITY_PRINCIPAL,
"cn=S. User, ou=NewHires, o=JNDITutorial");
env.put(Context.SECURITY_CREDENTIALS, "mysecret");
// Create the initial context
DirContext ctx = new InitialDirContext(env);
// ... do something useful with ctx
// Change to using no authentication
ctx.addToEnvironment(Context.SECURITY_AUTHENTICATION, "none");
// ... do something useful with ctx
设置连接超时
// Set up environment for creating initial context
Hashtable env = new Hashtable(11);
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.ldap.LdapCtxFactory");
env.put(Context.PROVIDER_URL, "ldap://localhost:389/o=JNDITutorial");
// Specify timeout to be 5 seconds
env.put("com.sun.jndi.ldap.connect.timeout", "5000");
// Create initial context
DirContext ctx = new InitialDirContext(env);
// do something useful with ctx
在此示例中,如果无法在5秒钟内创建连接,则将引发异常。
如果Context.PROVIDER_URL属性包含多个URL,则提供程序将对每个URL使用超时。例如,如果有3个URL,并且将超时指定为5秒,则提供程序将总共等待最多15秒。
关闭
普通垃圾回收会在不再使用Context实例时将其删除。由Context实例进行垃圾回收的连接将自动关闭。因此,您不需要显式关闭连接。但是,网络连接是有限的资源,对于某些程序,您可能希望控制它们的扩散和使用。
显示关闭
你在Context实例上调用Context.close()来指示您不再需要使用它。如果要关闭的Context实例正在使用专用连接,则该连接也将关闭。如果Context实例与其他Context和未终止的NamingEnumeration实例共享连接,则在所有此类Context和NamingEnumeration实例上调用close()之前,不会关闭该连接。
// Create initial context
DirContext ctx = new InitialDirContext(env);
// Get a copy of the same context
Context ctx2 = (Context)ctx.lookup("");
// Get a child context
Context ctx3 = (Context) ctx.lookup("ou=NewHires");
// do something useful with ctx, ctx2, ctx3
// Close the contexts when we're done
ctx.close();
ctx2.close();
ctx3.close();
强制隐式关闭
如前所述,对于那些不在范围内的Context和NamingEnumeration实例,Java运行时系统最终将对其进行垃圾回收,从而清除close()会完成的状态。要强制垃圾回收,可以使用以下代码。
Runtime.getRuntime().gc();
Runtime.getRuntime().runFinalization();
根据程序的状态,执行此过程可能会导致严重的(临时的)性能下降。如果需要确保关闭连接,请跟踪Context实例并显式关闭它们。
检测连接关闭
LDAP服务器通常有一个空闲超时时间,在此之后它们将关闭不再使用的连接。当你随后在使用此类连接的Context实例上调用方法时,该方法将引发CommunicationException。若要检测服务器何时关闭Context实例正在使用的连接,请向Context实例注册一个UnsolicitedNotificationListener。 LDAP未经请求的通知部分中显示了一个示例。尽管该示例旨在用于接收来自服务器的未经请求的通知,但它也可以用于检测服务器的连接关闭。启动程序后,停止LDAP服务器,并观察调用了侦听器的namingExceptionThrown()方法。
连接池
LDAP服务提供者支持的另一种类型的连接共享称为连接池。在这种共享类型中,LDAP服务提供者维护一个(可能)以前使用过的连接池,并根据需要将它们分配给上下文实例。当上下文实例通过连接完成时(关闭或垃圾回收),连接被返回到池中供将来使用。请注意,这种共享形式是顺序的:连接从池中检索,使用,返回到池中,然后从池中检索另一个上下文实例。
连接池是每个Java运行时系统维护的。对于某些情况,连接池可以显著提高性能。例如,如果使用连接池,则处理包含对同一LDAP服务器的四个引用的搜索响应只需要一个连接。如果没有连接池,这样的场景将需要四个独立的连接。
您可以通过将属性“ com.sun.jndi.ldap.connect.pool”添加到传递给初始上下文构造函数的环境属性来请求连接池。这是一个例子。
// Set up environment for creating initial context
Hashtable env = new Hashtable(11);
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
env.put(Context.PROVIDER_URL, "ldap://localhost:389/o=JNDITutorial");
// Enable connection pooling
env.put("com.sun.jndi.ldap.connect.pool", "true");
// Create one initial context (Get connection from pool)
DirContext ctx = new InitialDirContext(env);
// do something useful with ctx
// Close the context when we're done
ctx.close(); // Return connection to pool
// Create another initial context (Get connection from pool)
DirContext ctx2 = new InitialDirContext(env);
// do something useful with ctx2
// Close the context when we're done
ctx2.close(); // Return connection to pool
本示例连续创建两个初始上下文。第二个初始上下文将重用第一个初始上下文。要运行此程序并观察如何检索连接并将其返回到池,请使用以下命令行。
#java -Dcom.sun.jndi.ldap.connect.pool.debug=fine UsePool
这将产生如下所示的输出。
Create com.sun.jndi.ldap.LdapClient@5d173[localhost:389]
Use com.sun.jndi.ldap.LdapClient@5d173
{ou=ou: NewHires, objectclass=objectClass: top, organizationalUnit}
Release com.sun.jndi.ldap.LdapClient@5d173
Use com.sun.jndi.ldap.LdapClient@5d173
{ou=ou: People, objectclass=objectClass: top, organizationalunit}
Release com.sun.jndi.ldap.LdapClient@5d173
你可以通过包含或省略“ com.sun.jndi.ldap.connect.pool”属性来决定何时以及在何处使用池,从而基于上下文控制池。在上一个示例中,如果在创建第二个初始上下文之前从环境属性中删除了此属性,则第二个初始上下文将不使用池化连接。
LDAP提供程序通过应用程序的指示跟踪连接是否正在被使用。它假定维护开放上下文句柄的应用程序正在使用连接。因此,为了让LDAP提供者正确地管理池连接,你必须在你不再需要的上下文上谨慎地调用Context.close()。
LDAP提供程序会自动检测到错误的连接并将其从池中删除。无论是否使用连接池,上下文最终以错误的连接结尾的可能性都是相同的。
LDAP服务提供商维护的连接池可能会受到限制;连接池配置部分中对此进行了详细描述。当启用了连接池并且没有池连接可用时,客户端应用程序将阻塞,等待可用的连接。您可以使用“ com.sun.jndi.ldap.connect.timeout”环境属性来指定等待池化连接的时间。如果省略此属性,则应用程序将无限期等待。
什么时候不使用池!!
池连接旨在重用。因此,如果计划在Context实例上执行可能会更改基础连接状态的操作,则不应为该Context实例使用连接池。例如,如果您计划在Context实例上调用“启动TLS”扩展操作,或者计划在之后更改与安全相关的属性(例如“ java.naming.security.principal”或“ java.naming.security.protocol”),如果初始上下文已创建,则不应为该Context实例使用连接池,因为LDAP提供程序不会跟踪任何此类状态更改。如果在这种情况下使用连接池,则可能会损害应用程序的安全性。
连接池的配置这里也不过多概述
身份验证失败
身份验证可能由于多种原因而失败。例如,如果您提供了错误的身份验证信息,例如错误的密码或主体名称,则将抛出AuthenticationException。
这是先前示例的变体示例。这次,密码错误会导致身份验证失败。
// Authenticate as S. User and give an incorrect password
env.put(Context.SECURITY_AUTHENTICATION, "simple");
env.put(Context.SECURITY_PRINCIPAL,
"cn=S. User, ou=NewHires, o=JNDITutorial");
env.put(Context.SECURITY_CREDENTIALS, "notmysecret");
这将产生以下输出:
javax.naming.AuthenticationException: [LDAP: error code 49 - Invalid Credentials]
由于不同的服务器支持不同的身份验证机制,因此您可能会请求服务器不支持的身份验证机制。在这种情况下,将抛出AuthenticationNotSupportedException。
这是先前示例的变体示例。这次,不支持的身份验证机制('custom')导致身份验证失败。
// Authenticate as S. User and the password "mysecret"
env.put(Context.SECURITY_AUTHENTICATION, "custom");
env.put(Context.SECURITY_PRINCIPAL,
"cn=S. User, ou=NewHires, o=JNDITutorial");
env.put(Context.SECURITY_CREDENTIALS, "mysecret");
这将产生以下输出:
javax.naming.AuthenticationNotSupportedException: custom
LDAP Compare
LDAP“Compare”操作允许客户端询问服务器命名条目是否具有属性/值对。这允许服务器保持某些属性/值对的机密性(例如,不为一般的“搜索”访问而暴露),同时仍然允许客户端有限地使用它们。
例如,有些服务器可能将此特性用于密码,尽管客户机在“compare”操作本身传递明文密码是不安全的。
要在JNDI中完成此工作,请为以下方法使用适当约束的参数:
- search(Name name, String filter, SearchControls ctls)
- search(Name name, String filterExpr, Object[]filterArgs, SearchControls ctls)
1.过滤器的形式必须是"(name=value)"。你不能用通配符。
2.搜索范围必须是SearchControls.OBJECT_SCOPE。
3.您必须请求不返回任何属性。如果不满足这些条件,那么这些方法将使用LDAP“search”操作而不是LDAP“compare”操作。
下面是一个使用LDAP“比较”操作的示例:
// Value of the attribute
byte[] key = {(byte)0x61, (byte)0x62, (byte)0x63, (byte)0x64,
(byte)0x65, (byte)0x66, (byte)0x67};
// Set up the search controls
SearchControls ctls = new SearchControls();
ctls.setReturningAttributes(new String[0]); // Return no attrs
ctls.setSearchScope(SearchControls.OBJECT_SCOPE); // Search object only
// Invoke search method that will use the LDAP "compare" operation
NamingEnumeration answer = ctx.search("cn=S. User, ou=NewHires",
"(mySpecialKey={0})",
new Object[]{key}, ctls);
如果比较成功,则结果枚举将包含一个名称为空名称且不包含任何属性的项目。
Search Result
在DirContext界面中使用搜索方法时,将返回NamingEnumeration。 NamingEnumeration中的每个项目都是一个SearchResult,其中包含以下信息:
每个SearchResult包含满足搜索过滤条件的LDAP条目的名称。您可以通过使用getName()获得条目的名称。此方法返回相对于目标上下文的LDAP条目的组合名称。目标上下文是name参数解析到的上下文。用LDAP的话来说,目标上下文是搜索的基础对象。这是一个例子。
NamingEnumeration answer = ctx.search("ou=NewHires",
"(&(mySpecialKey={0}) (cn=*{1}))", // Filter expression
new Object[]{key, name}, // Filter arguments
null); // Default search controls
在此示例中,目标上下文是由“ ou = NewHires”命名的上下文。SearchResults在answer中的名称是相对于“ ou = NewHires”的。例如,如果getName()返回“ cn = J.Duke”,则其相对于ctx的名称将为“ cn = J.Duke,ou = NewHires”。
如果使用SearchControls.SUBTREE_SCOPE或SearchControls.OBJECT_SCOPE执行搜索,并且目标上下文本身满足搜索过滤器的要求,则返回的名称将为“”(空名称),因为这是相对于目标上下文的名称。
这一块我看的不是很懂,暂时先放着
LDAP主动通知
LDAP v3 (RFC 2251)定义了一个未经请求的通知,这是一个由LDAP服务器发送到客户机的消息,客户机没有任何挑衅。未经请求的通知在JNDI中由UnsolicitedNotification接口表示。
由于未经请求的通知是由服务器异步发送的,因此可以使用与接收有关名称空间更改和对象内容更改的通知相同的事件模型。你如果有兴趣接收未经请求的通知可以通过向EventContext或EventDirContext注册UnsolicitedNotificationListener
下面是UnsolicitedNotificationListener的一个示例。
public class UnsolListener implements UnsolicitedNotificationListener {
public void notificationReceived(UnsolicitedNotificationEvent evt) {
System.out.println("received: " + evt);
}
public void namingExceptionThrown(NamingExceptionEvent evt) {
System.out.println(">>> UnsolListener got an exception");
evt.getException().printStackTrace();
}
}
下面是一个向事件源注册UnsolicitedNotificationListener实现的示例。请注意,只有EventContext.addNamingListener()的listener参数是相关的。名称和范围参数与未经请求的通知无关。
// Get the event context for registering the listener
EventContext ctx = (EventContext)
(new InitialContext(env).lookup("ou=People"));
// Create the listener
NamingListener listener = new UnsolListener();
// Register the listener with the context (all targets equivalent)
ctx.addNamingListener("", EventContext.ONELEVEL_SCOPE, listener);
在运行这个程序时,您需要将它指向一个LDAP服务器,该服务器可以生成未经请求的通知,并促使服务器发出通知。否则,一分钟后程序将无声地退出。
实现UnsolicitedNotificationListener的侦听器也可以实现其他NamingListener接口,例如NamespaceChangeListener和ObjectChangeListener。
最后附上一个例子:
import javax.naming.Context;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.naming.ldap.InitialLdapContext;
import javax.naming.ldap.LdapContext;
import java.util.Hashtable;
/**
* Ldap util
*/
public class LdapConnectUtil {
private static final String LDAP_URL = "ldap://192.168.201.201:389";
private static final String USER_NAME = "Administrator";
private static final String DOMAIN = "xxx.xxx.com";
private static final String DOMAIN_NODE = "dc=aaa,dc=bbb,dc=ccc";
private static final String PASSWORD = "123456";
/**
* connect Ldap
* @return LdapContext
*/
public static LdapContext adLogin() {
try {
Hashtable<String, String> env = new Hashtable<>();
env.put(Context.SECURITY_PRINCIPAL, USER_NAME+"@"+DOMAIN);
env.put(Context.SECURITY_CREDENTIALS, PASSWORD);
env.put(Context.PROVIDER_URL, LDAP_URL);
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
// 认证机制 sasl_mech
env.put(Context.SECURITY_AUTHENTICATION, "simple");
return new InitialLdapContext(env, null);
} catch (NamingException e) {
e.printStackTrace();
return null;
}
}
public static String checkUserExist(String name) {
LdapContext ldapContext = adLogin();
if (null != ldapContext){
try {
// Query domain account
String searchFilter = "(sAMAccountName="+name+")";
SearchControls searchControls = new SearchControls();
//String[] returnedAtts = {"description","sAMAccountName","userAccountControl"};
String[] returnedAtts = {"description","sAMAccountName","distinguishedName"};
// Set the specified return field, if not set, return all
searchControls.setReturningAttributes(returnedAtts);
// Set search range , depth
searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
// Search LDAP according to the set domain node, filter class and search controller to get the result
NamingEnumeration<SearchResult> searchResults = ldapContext.search(DOMAIN_NODE, searchFilter,searchControls);
Attributes attributes = searchResults.next().getAttributes();
Attribute distinguishedName = attributes.get("distinguishedName");
if (null != distinguishedName){
return distinguishedName.get().toString();
}else {
return null;
}
} catch (NamingException e) {
e.printStackTrace();
} finally {
try{
ldapContext.close();
}catch (Exception e){
e.printStackTrace();
}
}
}
return null;
}
}
本文来自博客园,作者:SnailsH,转载请注明原文链接:https://www.cnblogs.com/SnailsWalk/p/17976248