zoukankan      html  css  js  c++  java
  • 9. Technical Overview 技术概述

    9.1 Runtime Environment 运行环境

    Spring Security 3.0需要Java 5.0运行时环境或更高版本。由于Spring Security的目标是以独立的方式运行,所以没有必要将任何特殊的配置文件放入Java运行时环境中。特别是,不需要配置特殊的Java身份验证和授权服务(JAAS)策略文件,也不需要将Spring Security放在公共的类路径位置。

    同样,如果您使用的是EJB容器或Servlet容器,就不需要在任何地方放置任何特殊的配置文件,也不需要在服务器类加载器中包含Spring Security。所有必需的文件都将包含在您的应用程序中。

    这种设计提供了最大的部署时间灵活性,因为您可以简单地将您的目标工件(无论是JAR、WAR还是EAR)从一个系统复制到另一个系统,它将立即工作。

    9.2 Core Components (核心组件)

    在Spring Security 3.0中,spring-security-core jar的内容被剥离到最低限度。它不再包含任何与网络应用安全、LDAP或命名空间配置相关的代码。我们将在这里看一看核心模块中的一些Java类型。它们代表框架的构建块,所以如果您需要简单的名称空间配置,那么理解它们是什么很重要,即使您实际上不需要直接与它们交互。

    9.2.1 SecurityContextHolder, SecurityContext and Authentication Objects(安全上下文持有者、安全上下文和身份验证对象)

    最基本的对象是SecurityContextHolder(安全上下文持有者)。我们在这里存储应用程序当前安全上下文的详细信息,包括当前使用该应用程序的主体的详细信息。默认情况下,SecurityContextHolder使用ThreadLocal来存储这些细节,这意味着安全上下文始终对同一执行线程中的方法可用,即使安全上下文没有作为参数显式传递给这些方法。这种情况下使用ThreadLocal是非常安全的,只要记得在处理完当前主体的请求以后,把这个线程清除就行了。当然,Spring Security自动帮你管理这一切了, 你就不用担心什么了。

    有些程序并不适合使用ThreadLocal,因为它们处理线程的特殊方法。比如Swing客户端也许希望Java Virtual Machine里所有的线程 都使用同一个安全环境。SecurityContextHolder可以在启动时配置策略,以指定您希望如何存储上下文。对于独立的应用程序,您可以使用SecurityContextHolderSecurityContextHolder可以在启动时配置策略,以指定您希望如何存储上下文。对于独立的应用程序,您可以使用SecurityContextHolder.MODE_GLOBAL策略。其他程序可能也想由安全线程产生的线程也承担同样的安全标识。这是通过使用SecurityContextHolder.MODE_INHERITABLETHREADLOCAL实现。第一个是设置系统属性,第二个是调用SecurityContextHolder的静态方法。大多数应用程序不需要修改默认值,但是如果你想要修改,可以看一下SecurityContextHolder的JavaDocs中的详细信息了解更多。

    Obtaining information about the current user(获取关于当前用户的信息)

    在SecurityContextHolder中,我们存储当前与应用程序交互的主体的详细信息。Spring Security使用一个身份验证对象来表示这些信息。您通常不需要自己创建身份验证对象,但是用户查询身份验证对象是很常见的。您可以从应用程序中的任何位置使用以下代码块来获取当前经过身份验证的用户的名称,例如:


     

    调用getContext()返回的对象是SecurityContext接口的一个实例。这是保存在线程本地存储中的对象。正如我们将在下面看到的,Spring Security中的大多数身份验证机制都返回一个UserDetails实例作为主体。

    9.2.2 The UserDetailsService(用户详细信息服务)

    从上面的代码片段中需要注意的另一点是,您可以从身份验证对象中获取一个主体。主体只是一个客体。大多数情况下,这可以转换成一个UserDetails对象。UserDetails是Spring安全的核心接口。它代表一个主体,但是以一种可扩展的和特定于应用程序的方式。将UserDetails视为您自己的用户数据库和SecurityContextHolder内部的Spring Security需要之间的适配器。作为来自您自己的用户数据库的表示,您经常会将UserDetails转换为您的应用程序提供的原始对象,因此您可以调用特定于业务的方法(如getEmail(),getEmployeeNumber(),等等)。

    现在你可能想知道,我什么时候提供一个用户详细信息对象?我怎么做呢?我想你说这个东西是声明式的,我不需要写任何代码——什么给出了?

    简而言之,有一个叫做UserDetailsService(用户详细服务)的特殊接口。此接口上的唯一方法接受基于字符串的用户名参数并返回UserDetails(用户详细信息):


     

    这是在Spring Security中为用户加载信息的最常见的方法,当需要关于用户的信息时,您会在整个框架中看到它的使用。

    在成功的身份验证中,UserDetails(用户详细信息)用于构建存储在SecurityContextHolder中的Authentication(身份验证对象)(下面将详细介绍)。好消息是我们提供了大量的用户细节服务实现,包括一个使用内存映射(InMemoryDaoImpl)的实现和一个使用JDBC (JdbcDaoImpl)的实现。然而,大多数用户倾向于编写自己的代码,他们的实现通常只是简单地位于代表他们的雇员、客户或应用程序的其他用户的现有数据访问对象(DAO)之上。请记住,无论您的UserDetailsService返回什么,都可以使用上面的代码片段从安全上下文持有者获得。

    关于UserDetailsService经常会有一些混淆。它纯粹是用户数据的DAO,除了向框架内的其他组件提供数据之外,不执行其他功能。特别是,它不会对用户进行身份验证,这是由AuthenticationManager完成的。在许多情况下,如果需要自定义身份验证过程,直接实现AuthenticationProvider更有意义。

    9.2.3 GrantedAuthority (授予授权)

    除了主体,身份验证提供的另一个重要方法是getAuthorities()。这个方法提供了一个授权对象的数组。毫不奇怪,授权是授予委托人的权力。这种权限通常是“角色”,例如ROLE_ADMINISTRATOR或ROLE _ HR _ SUPERVISOR。这些角色稍后将针对web授权、方法授权和域对象授权进行配置。Spring Security的其他部分能够解释这些权限,并期望它们存在。授权对象通常由UserDetailsService加载。

    通常授予的权限对象是应用程序范围的权限。它们并不特定于给定的域对象。因此,您不太可能拥有授权来表示对雇员对象编号54的权限,因为如果有成千上万个这样的权限,您将很快耗尽内存(或者至少导致应用程序花很长时间来验证用户)。当然,Spring Security是专门为处理这种常见需求而设计的,但是您可以使用项目的域对象安全功能来实现这一目的。

    9.2.4 Summary  摘要

    简单回顾一下,目前为止我们看到的Spring Security的主要构建模块是:

    SecurityContextHolder,提供对SecurityContext的访问。

    SecurityContext,用于保存Authentication和可能的特定于请求的安全信息。

    Authentication,以Spring安全特定的方式代表主体。

    GrantedAuthority,以反映授予主体的应用程序范围的权限。

    UserDetails,提供必要的信息,以便从应用程序的DAOs或其他安全数据源构建身份验证对象。

    UserDetailsService,当传入基于字符串的用户名(或证书标识等)时创建UserDetails。

    现在您已经了解了这些重复使用的组件,让我们更仔细地看看身份验证的过程。

    9.3 Authentication (权限认证)

    Spring Security可以参与许多不同的身份验证环境。虽然我们建议人们使用Spring Security进行身份验证,而不要与现有的容器管理身份验证集成,但是它仍然受到支持,就像与您自己的专有身份验证系统集成一样。

    9.3.1什么是spring安全认证?

    让我们考虑一个大家都熟悉的标准身份验证场景。

    1、提示用户输入用户名和密码进行登录。

    2、系统(成功)验证用户名的密码是否正确。

    3、获取该用户的上下文信息(他们的角色列表等等)。

    4、为用户建立安全上下文。

    5、用户继续执行一些操作,这些操作可能受到访问控制机制的保护,该机制根据当前的安全上下文信息检查操作所需的权限。

    前三项构成了身份验证过程,因此我们将看看这些是如何在Spring Security中发生的。

    1、用户名和密码被获取并组合到一个UsernamePasswordAuthenticationToken的实例中(这是我们前面看到的身份验证接口的一个实例)。

    2、令牌被传递给AuthenticationManager的一个实例进行验证。

    3、AuthenticationManager在身份验证成功时返回完全填充的Authentication。

    4、通过调用securitycontextholder . GetContext()来建立安全上下文。设置身份验证(…),传入返回的身份验证对象。

    从这一点上来看,用户被认为是被验证的。让我们看看一些代码作为一个例子:


     

     

    这里,我们编写了一个小程序,要求用户输入用户名和密码,并执行上面的序列。我们在这里实现的身份验证管理器将验证用户名和密码相同的任何用户。它为每个用户分配一个角色。上面的输出将类似于:


     

    请注意,你通常不需要写任何这样的代码。这个过程通常会发生在内部,以一个web认证过滤器为例,我们刚刚在这里的代码显示,在Spring Security中究竟是什么构成了验证的问题,有一个相对简单的答案。用户验证时,SecurityContextHolder包含一个完全填充的Authentication对象的用户进行身份验证。

    默认情况下,使用StrictHttpFirewall。该实现拒绝看似恶意的请求。如果它对你的需求来说太严格,那么你可以定制什么类型的请求被拒绝。然而,重要的是你要知道这可能会使你的应用程序受到攻击。例如,如果您希望利用Spring MVC的矩阵变量,可以在XML中使用以下配置:


     

    同样的事情也可以通过Java配置来实现,方法是公开一个StrictHttpFirewall bean。


     

    9.3.2 Setting the SecurityContextHolder Contents Directly(直接设置安全上下文持有者内容)

    事实上,Spring Security并不介意如何将身份验证对象(Authentication)放入SecurityContextHolder中。唯一关键的要求是SecurityContextHolder包含一个身份验证(Authentication),该身份验证在抽象安全接口(AbstractSecurityInterceptor)(我们将在后面详细讨论)需要授权用户操作之前代表一个主体。

    您可以(许多用户也可以)编写自己的过滤器或MVC控制器,以提供与不基于Spring安全的身份验证系统的互操作性。例如,您可能正在使用容器管理的身份验证,这使得当前用户可以从线程本地或JNDI位置访问。或者,你可能在一家拥有传统专有认证系统的公司工作,这是一个你几乎无法控制的公司“标准”。在这种情况下,很容易让Spring Security工作,并且仍然提供授权功能。您需要做的只是编写一个过滤器(或等效工具),从一个位置读取第三方用户信息,构建一个Spring安全特定的身份验证对象(Authentication),并将其放入安全上下文持有者(SecurityContextHolder)。在这种情况下,您还需要考虑通常由内置身份验证基础结构自动处理的事情。例如,在向客户端脚注写入响应之前,您可能需要先创建一个HTTP会话来缓存请求之间的上下文:一旦提交了响应,就不可能创建会话。

    如果您想知道AuthenticationManager是如何在实际示例中实现的,我们将在核心服务一章( core services chapter)中讨论这一点。

    9.4 Authentication in a Web Application (web应用中的认证)

    现在让我们探讨一下在web应用程序中使用Spring Security的情况(没有启用web.xml安全性)。如何对用户进行身份验证并建立安全上下文?

    考虑一个典型的web应用程序的身份验证过程:

    1、你访问主页,点击一个链接。

    2、一个请求到达服务器,服务器决定您已经请求了一个受保护的资源。

    3、由于您目前没有通过身份验证,服务器会发回一个响应,指示您必须通过身份验证。该响应或者是一个HTTP响应代码,或者是一个到特定网页的重定向。

    4、根据身份验证机制,您的浏览器要么重定向到特定的网页以便您填写表单,要么浏览器以某种方式检索您的身份(通过基本身份验证对话框、cookie、X.509证书等)。)。

    5、浏览器将向服务器发回响应。这要么是一个包含您填写的表单内容的HTTP POST,要么是一个包含您的身份验证详细信息的HTTP标头。

    6、接下来,服务器将决定呈现的凭证是否有效。如果它们是有效的,下一步就会发生。如果无效,通常你的浏览器会被要求再试一次(所以你回到上面的第二步)。

    7、将重试您为导致身份验证过程而提出的原始请求。希望你已经通过了足够的授权来访问受保护的资源。如果您有足够的访问权限,请求将会成功。否则,您将收到一个返回的HTTP错误代码403,这意味着“禁止”。

    Spring Security有不同的类来负责上面描述的大部分步骤。主要参与者(按使用顺序)是异常转换过滤器(ExceptionTranslationFilter),一个AuthenticationEntryPoint和一个“身份验证机制(authentication mechanism)”,负责调用我们在上一节中看到的AuthenticationManager。

    9.4.1 ExceptionTranslationFilter (异常转换过滤器)

    ExceptionTranslationFilter是一个Spring安全筛选器,负责检测引发的任何Spring安全异常。这种异常通常会被抽象安全接口(AbstractSecurityInterceptor)抛出,它是授权服务的主要提供者。我们将在下一节讨论抽象安全接口,但是现在我们只需要知道它产生了Java异常,并且对HTTP或者如何验证主体一无所知。相反,ExceptionTranslationFilter提供此服务,具体负责返回错误代码403(如果主体已经过身份验证,因此缺少足够的访问权限,如上面的步骤七),或者启动AuthenticationEntryPoint(如果主体尚未通过身份验证,因此我们需要开始步骤三)。

    9.4.2 AuthenticationEntryPoint (认证入口点)

    AuthenticationEntryPoint负责上述列表中的第三步。可以想象,每个web应用程序都有一个默认的身份验证策略(可以像Spring Security中的其他任何东西一样进行配置,但是现在让我们保持简单)。每个主要的身份验证系统都有自己的AuthenticationEntryPoint实现,它通常执行步骤3中描述的操作之一。

    9.4.3 Authentication Mechanism(认证机制)

    一旦您的浏览器提交了您的身份验证凭据(或者作为一个HTTP表单帖子,或者作为一个HTTP头),服务器上就需要有一些东西来“收集”这些身份验证的详细信息。到现在为止,我们已经在上面列表的第六步了。在Spring Security中,我们为从用户代理(通常是web浏览器)收集身份验证详细信息的功能起了一个特殊的名字,将其称为“身份验证机制(authentication mechanism)”。例如基于表单的登录和基本身份验证。一旦从用户代理收集了身份验证详细信息,就会构建一个身份验证(Authentication)“请求”对象,然后呈现给身份验证管理器(AuthenticationManager)。

    在身份验证机制收到完全填充的身份验证(Authentication)对象后,它将认为请求有效,将身份验证(Authentication)放入SecurityContextHolder中,并重试原始请求(上面的第七步)。另一方面,如果AuthenticationManager拒绝了请求,身份验证机制将要求用户代理重试(上面的第二步)。

    9.4.4 Storing the SecurityContext between requests(在请求之间存储安全性上下文)

    根据应用程序的类型,可能需要一个策略来存储用户操作之间的安全上下文。在典型的web应用程序中,用户登录一次,随后由他们的会话Id标识。服务器缓存持续会话的主体信息。在Spring Security中,在请求之间存储SecurityContext的责任属于SecurityContextPersistenceFilter,默认情况下,它将上下文存储为HTTP请求之间的HTTP会话属性。它将每个请求的上下文还原到SecurityContextHolder,并且在请求完成时清除SecurityContextHolder。出于安全目的,您不应该直接与HttpSession交互。这样做是没有任何理由的——总是使用SecurityContextHolder。

    许多其他类型的应用程序(例如,无状态的RESTful web服务)不使用HTTP会话,并且会在每次请求时重新进行身份验证。但是,链中包含securitycontextPersistenceFilter仍然很重要,以确保每次请求后都清除了SecurityContextHolder。

    在单个会话中接收并发请求的应用程序中,同一安全上下文实例将在线程之间共享。即使正在使用线程本地,它也是从每个线程的HttpSession中检索的同一个实例。如果您希望临时更改线程运行的上下文,这是有意义的。如果您只使用securitycontextholder . GetContext(),并对返回的上下文对象调用设置身份验证(anAuthentication),则身份验证对象将在共享同一个SecurityContext实例的所有并发线程中更改。您可以自定义securitycontextPersistenceFilter的行为,为每个请求创建一个全新的SecurityContext,防止一个线程中的更改影响另一个线程。或者,您可以在临时更改上下文的位置创建一个新实例。方法securitycontextholder . createemptycontext()总是返回一个新的上下文实例。

    9.5 Access-Control (Authorization) in Spring Security(Spring安全中的访问控制(授权))

    在Spring Security中,负责做出访问控制决策的主要接口是访问决策管理器(AccessDecisionManager)。它有一个decide方法,它需要一个Authentication对象请求访问、一个"secure object"(安全对象)(见下文)和安全元数据属性的列表适用的对象(例如授予访问所需的角色列表)。

    9.5.1 Security and AOP Advice(安全和AOP建议)

    如果你熟悉AOP,你会意识到有不同类型的建议可用:之前,之后,抛出和周围。循环建议非常有用,因为advisorSpring Security为方法调用和web请求提供了一个全面的建议。我们使用Spring的标准AOP支持实现了方法调用的循环建议,并使用标准过滤器实现了web请求的循环建议。可以选择是否继续方法调用、是否修改响应以及是否抛出异常。

    对于那些不熟悉AOP的人来说,需要理解的关键一点是,Spring安全性可以帮助您保护方法调用和web请求。多数人对保护他们服务层上的方法调用感兴趣。这是因为服务层是当前一代Java EE应用程序中大多数业务逻辑所在的地方。如果您只需要保护服务层中的方法调用,Spring的标准AOP就足够了。如果您需要直接保护域对象,您可能会发现AspectJ是值得考虑的。

    您可以选择使用AspectJ或Spring AOP来执行方法授权,也可以选择使用过滤器来执行web请求授权。你可以同时使用零、一、二或三种方法。主流的使用模式是执行一些web请求授权,再加上服务层上的一些Spring AOP方法调用授权。

    9.5.2 Secure Objects and the AbstractSecurityInterceptor(安全对象和AbstractSecurityInterceptor)

    那么什么是“安全对象”?Spring Security使用该术语来指代任何可以应用安全性(例如授权决策)的对象。最常见的例子是方法调用和web请求。

    每个受支持的安全对象类型都有自己的拦截器类,它是抽象安全拦截器(AbstractSecurityInterceptor)的子类。重要的是,在调用抽象安全拦截器(AbstractSecurityInterceptor)时,如果主体已经过身份验证,安全上下文持有者(SecurityContextHolder)将包含有效的身份验证(Authentication)。

    抽象安全拦截器(AbstractSecurityInterceptor)为处理安全对象请求提供了一致的工作流,通常:

    1、查找与当前请求相关联的“配置属性”。

    2、将安全对象、当前身份验证和配置属性提交给访问决策管理器(AccessDecisionManager)进行授权决策。

    3、有可能在调用的过程中,对Authentication进行修改。

    4、允许安全对象调用继续进行(假设授予了访问权限)。

    5、调用返回后,调用AfterInvocationManager(如果已配置)。如果调用引发了异常,将不会调用AfterInvocationManager。

    What are Configuration Attributes?(什么是配置属性?)

    一个"配置属性"可以看做是一个字符串,它对于AbstractSecurityInterceptor使用的类是有特殊含义的。它们由框架中的接口ConfigAttribute表示。它们可能是简单的角色名,也可能有更复杂的含义,这取决于AccessDecisionManager实现的复杂程度。AbstractSecurityInterceptor配置有一个安全数据源(SecurityMetadataSource),用于查找安全对象的属性。通常这种配置对用户是隐藏的。配置属性将作为安全方法上的注释或安全网址上的访问属性输入。例如,当我们在名称空间介绍中看到类似< intercept-url    pattern='/secure/**' access='ROLE_A,ROLE_B'/>的内容时,这意味着配置属性ROLE_A和ROLE_B适用于与给定模式匹配的web请求。实际上,使用默认的访问决策管理器(AccessDecisionManager)配置,这意味着任何拥有与这两个属性之一匹配的授权(GrantedAuthority)的人都将被允许访问。严格地说,它们只是属性,解释依赖于AccessDecisionManager的实现。前缀ROLE_的使用是一个标记,表示这些属性是角色,应该由Spring Security的RoleVoter使用。只有在使用基于投票者的访问决策管理器(AccessDecisionManager)时,这才是相关的。我们将在授权一章中看到如何实现AccessDecisionManager。

    Run As Manager(运行管理器)

    假设AccessDecisionManager决定允许执行这个请求,AbstractSecurityInterceptor会正常执行这个请求。话虽如此,在极少数情况下,用户可能希望用不同的身份验证来替换安全上下文(SecurityContext)中的身份验证(Authentication),该身份验证由调用运行管理器(RunAsManager)的访问决策管理器(AccessDecisionManager)来处理。

    在相当不常见的情况下,例如服务层方法需要调用远程系统并显示不同的标识时,这可能很有用。 由于Spring Security会自动将安全身份从一台服务器传播到另一台服务器(假设您使用的是正确配置的RMI或HttpInvoker远程协议客户端),因此这很有用。

    After Invocation Manager (调用后管理器)

    在安全对象调用继续进行之后,然后返回-这可能意味着方法调用完成或过滤器链继续进行-AbstractSecurityInterceptor获得了处理调用的最后机会。在此阶段,AbstractSecurityInterceptor对可能修改返回对象感兴趣。 我们可能希望发生这种情况,因为无法在安全对象调用的“途中”做出授权决定。由于高度可插拔,AbstractSecurityInterceptor会将控制权传递给AfterInvocationManager,以根据需要实际修改对象。 此类甚至可以完全替换对象,或者引发异常,也可以按照其选择的任何方式对其进行更改。调用后检查仅在调用成功的情况下执行。 如果发生异常,将跳过其他检查。图9.1“安全拦截器和“安全对象”模型”中显示了AbstractSecurityInterceptor及其相关对象。


     


    Extending the Secure Object Model(扩展安全对象模型)

    只有考虑一种全新的拦截和授权请求方式的开发人员才需要直接使用安全对象。例如,可以构建一个新的安全对象来保护对消息传递系统的调用。任何需要安全性并且提供拦截调用方式的东西(比如关于建议语义的AOP)都能够被做成一个安全的对象。尽管如此,大多数Spring应用程序将简单地使用当前支持的三种安全对象类型(AOP联盟方法定位、AspectJ连接点和web请求过滤器调用),并且完全透明。

    9.6 Localization 本地化

    Spring Security支持最终用户可能看到的异常消息的本地化。如果您的应用程序是为说英语的用户设计的,您不需要做任何事情,因为默认情况下所有的安全消息都是英语的。如果您需要支持其他语言环境,您需要知道的一切都包含在本节中。

    所有异常消息都可以本地化,包括与身份验证失败和访问被拒绝(授权失败)相关的消息。针对开发人员或系统部署人员的异常和日志消息(包括不正确的属性、违反接口约定、使用不正确的构造函数、启动时间验证、调试级日志记录)没有本地化,而是在Spring Security的代码中用英语硬编码。

    在spring-security-core-xx.jar中,您会找到一个org.springframework.security包,该包又包含一个messages.properties文件,以及一些常见语言的本地化版本。这应该由您的应用程序上下文来引用,因为Spring Security 类实现了Spring的MessageSourceAware 接口,并期望消息解析器在应用程序上下文启动时被依赖注入。通常,您需要做的只是在应用程序上下文中注册一个bean来引用消息。下面是一个例子:


     

    messages.properties是根据标准资源包命名的,代表Spring Security消息支持的默认语言。这个默认文件是英文的。

    如果您希望自定义messages.properties文件,或者支持其他语言,您应该复制该文件,对其进行相应的重命名,并在上面的bean定义中注册它。

    这个文件中没有大量的消息键,所以本地化不应该被认为是一个主要的举措。如果您确实执行了此文件的本地化,请考虑通过记录JIRA任务并附加适当命名的本地化版本的messages.properties来与社区共享您的工作。

    spring安全依赖于Spring的本地化支持,以便实际查找适当的消息。为了实现这一点,您必须确保来自传入请求的区域设置存储在Spring的org . Spring framework . context . i18n . localeContextholder中。Spring MVC的DispatcherServlet会自动为您的应用程序执行此操作,但是由于Spring Security的过滤器是在此之前调用的,因此在调用过滤器之前,需要将LocaleContextHolder设置为包含正确的区域。您可以自己在一个过滤器中完成这项工作(这个过滤器必须在Spring Security过滤器之前)。有关在Spring中使用本地化的更多详细信息,请参考Spring框架文档。“contacts人”示例应用程序被设置为使用本地化消息。

  • 相关阅读:
    Java实现 蓝桥杯VIP 算法训练 校门外的树
    Java实现 蓝桥杯VIP 算法训练 统计单词个数
    Java实现 蓝桥杯VIP 算法训练 统计单词个数
    Java实现 蓝桥杯VIP 算法训练 开心的金明
    Java实现 蓝桥杯VIP 算法训练 开心的金明
    Java实现 蓝桥杯 算法训练 纪念品分组
    Java实现 蓝桥杯 算法训练 纪念品分组
    Java实现 蓝桥杯VIP 算法训练 校门外的树
    Java实现 蓝桥杯VIP 算法训练 统计单词个数
    Java实现 蓝桥杯VIP 算法训练 开心的金明
  • 原文地址:https://www.cnblogs.com/jrkl/p/13513151.html
Copyright © 2011-2022 走看看