博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
ASP.NET Core 实战:基于 Jwt Token 的权限控制全揭露
阅读量:4992 次
发布时间:2019-06-12

本文共 18194 字,大约阅读时间需要 60 分钟。

 一、前言

  在涉及到后端项目的开发中,如何实现对于用户权限的管控是需要我们首先考虑的,在实际开发过程中,我们可能会运用一些已经成熟的解决方案帮助我们实现这一功能,而在 Grapefruit.VuCore 这个项目中,我将使用 Jwt 的方式实现对于用户的权限管控,在本章中,我将演示如何使用 Jwt 实现对于用户的授权、鉴权。

  系列目录地址:

  仓储地址:

 二、Step by Step

  1、一些概念

  Jwt(json web token),是一种基于 Json 的无状态授权令牌,因为 Jwt 是一种标准的数据传输规范,并不是某家所独有的技术规范,因此非常适用于构建单点登录服务,为 web、client、app 等等各种接口使用方提供授权服务。

  在使用 Jwt 进行权限控制的过程中,我们需要先请求授权服务器获取到 token 令牌,将令牌存储到客户端本地(在 web 项目中,我们可以将 token 存储到 localstorage 或是 cookie 中),之后,对于服务端的每一次请求,都需要将获取到的 token 信息添加到 http 请求的 header 中。

$.ajax({    url: url,    method: "POST",    data: JSON.stringify(data),    beforeSend: function (xhr) {        /* Authorization header */        xhr.setRequestHeader("Authorization", "Bearer " + token);    },    success: function (data) {}});

  当用户拥有令牌后是否就可以访问系统的所有功能了呢?答案当然否定的。对于一个系统来说可能会有多种用户角色,每一个用户角色可以访问的资源也是不同的,所以,当用户已经拥有令牌后,我们还需要对用户角色进行鉴定,从而做到对用户进行进一步的权限控制。

  在 Grapefruit.VuCore 这个项目中,我采用的是基于策略的授权方式,通过定义一个授权策略来完善 Jwt 鉴权,之后将这个自定义策略注入到 IServiceCollection 容器中,对权限控制做进一步的完善,从而达到对于用户访问权限的管控。

  基于策略的授权是微软在 ASP.NET Core 中添加的一种新的授权方式,通过定义好策略(policy)的一个或多个要求(requirements),将这个自定义的授权策略在 Startup.ConfigureServices 方法中作为授权服务配置的一部分进行注册之后即可按照我们的策略处理程序进行权限的控制。

services.AddAuthorization(options =>{    options.AddPolicy("Permission",       policy => policy.Requirements.Add(new PolicyRequirement()));})services.AddSingleton
();

  就像我在后面的代码中一样,我定义了一个名叫 Permission 的授权策略,它包含了一个叫做 PolicyRequirement 的鉴权要求,在实现了授权策略后,将基于这个要求的鉴权方法 PolicyHandler 以单例(AddSingleton)的形式注入到服务集合中,此时,我们就可以在需要添加验证的 controller 上添加 attribute 即可。

[Authorize(Policy = "Permission")]public class SecretController : ControllerBase{}

  2、授权

  在 Grapefruit.VuCore 这个项目中,涉及到授权相关的代码所在的位置我已在下图中进行标示。在之前系列开篇文章()进行介绍整个项目框架时曾说到, Grapefruit.Application 是项目的应用层,顾名思义,就是为了实现我们项目中的实际业务功能所划分的类库。因此,我们实现 Jwt 的相关业务代码应该位于此层中。同时,因为对于 Jwt 的令牌颁发与鉴权,采用的是微软的 JwtBearer 组件,所以我们在使用前需要先通过 Nuget 将引用添加到 Grapefruit.Application 上。

Install-Package Microsoft.AspNetCore.Authentication.JwtBearerInstall-Package System.IdentityModel.Tokens.Jwt

  在 Grapefruit.Application 这个类库下我创建了一个  Authorization 解决方案文件夹用来存储授权相关的代码。在 Authorization 这个解决方案文件夹中包含了两个子文件夹 Jwt 和 Secret。Jwt 文件夹中主要包含我们对于 Jwt 的操作,而 Secret 文件夹下则是对于用户的相关操作。

  每个子应用文件夹(Jwt、Secret)都包含了相同的结构:Dto 数据传输对象、功能接口,以及功能接口的实现类,这里接口的继承采用单继承的方式。

  在 Jwt 文件夹下创建一个 IJwtAppService 接口文件,在这里定义我们对于 Jwt 的相关操作。因为对于 Jwt 的授权、鉴权是采用微软的 JwtBearer 组件,我们只需要进行配置即可,所以这里只定义对于 token 的生成、刷新、停用,以及判断这个 token 是否有效这几个方法。同时,我们需要创建 JwtAppService 这个类文件,去继承 IJwtAppService 从而实现接口功能。

public interface IJwtAppService{    ///     /// 新增 Jwt token    ///     /// 用户信息数据传输对象    /// 
JwtAuthorizationDto Create(UserDto dto); /// /// 刷新 Token /// /// Token /// 用户信息数据传输对象 ///
Task
RefreshAsync(string token, UserDto dto); ///
/// 判断当前 Token 是否有效 /// ///
Task
IsCurrentActiveTokenAsync(); ///
/// 停用当前 Token /// ///
Task DeactivateCurrentAsync(); ///
/// 判断 Token 是否有效 /// ///
Token ///
Task
IsActiveAsync(string token); ///
/// 停用 Token /// ///
Task DeactivateAsync(string token);}

  JwtAuthorizationDto 是一个 token 信息的传输对象,包含我们创建好的 token 相关信息,用来将 token 信息返回给前台进行使用。而 UserDto 则是用户登录获取 token 时的数据传输对象,用来接收登录时的参数值。

  在创建 token 或是验证 token 时,像 token 的颁发者、接收者之类的信息,因为会存在多个地方调用的可能性,这里我将这些信息存放在了配置文件中,后面当我们需要使用的时候,只需要通过注入 IConfiguration 进行获取即可。关于 Jwt 的配置文件主要包含了四项:token 的颁发者,token 的接收者,加密 token 的 key 值,以及 token 的过期时间,你可以根据你自己的需求进行调整。

"Jwt": {    "Issuer": "yuiter.com",    "Audience": "yuiter.com",    "SecurityKey": "a48fafeefd334237c2ca207e842afe0b",    "ExpireMinutes": "20"}

  在 token 的创建过程中可以简单拆分为三个部分:根据配置信息和用户信息创建一个 token,将加密后的用户信息写入到 HttpContext 上下文中,以及将创建好的 token 信息添加到静态的 HashSet<JwtAuthorizationDto> 集合中。

  在 token 创建、校验的整个生命周期中,都涉及到了  Scheme、Claim、ClaimsIdentity、ClaimsPrincipal 这些概念,如果你之前有使用过微软的 Identity 权限验证,对于这几个名词就会比较熟悉,可能某些小伙伴之前并没有使用过 Identity,我来简单介绍下这几个名词的含义。

  Scheme 模式,这个与其余的名词相对独立,它主要是指明我们是以什么授权方式进行授权的。例如,你是以 cookie 的方式授权或是以 OpenId 的方式授权,或是像这里我们使用 Jwt Bearer 的方式进行授权。

  Claim 声明,以我们的现实生活为例,我们每个人都会有身份证,上面会包含我们的姓名、性别、民族、出生日期、家庭住址、身份证号,每一项数据的都可以看成是 type-value(数据类型-数据值),例如,姓名:张三。身份证上的每一项的信息就是我们的 Claim 声明,姓名:张三,是一个 Claim;性别:男,也是一个 Claim。而对于 ClaimsIdentity,就像这一项项的信息最终组成了我们的身份证,这一项项的 Claim 最终组成了我们的 ClaimsIdentity。而 ClaimsPrincipal 则是 ClaimsIdentity 的持有者,就像我们拥有身份证一样。

  从上面的文字可以总结出,Claim(每一项的证件信息)=》ClaimsIdentity(证件)=》ClaimsPrincipal(证件持有者)。其中,一个 ClaimsIdentity 可以包含多个的 Claim,而一个 ClaimsPrincipal 可以包含多个的 ClaimsIdentity。

   如果想要深入了解 ASP.NET Core 的授权策略的可以看看园子里这篇文章 =》,或是国外的这篇介绍 ASP.NET Core 授权的文章 =》。

  实现 token 生成的最终代码实现如下所示,可以看到,在创建 ClaimsIdentity “证件”信息时,我添加了用户的角色信息,并把加密后的用户信息写入到 HttpContext 上下文中,这样,我们在后面验证的时候就可以通过 HttpContext 获取到用户的角色信息,从而判断用户是否可以访问当前请求的地址。

/// /// 新增 Token/// /// 用户信息数据传输对象/// 
public JwtAuthorizationDto Create(UserDto dto){ JwtSecurityTokenHandler tokenHandler = new JwtSecurityTokenHandler(); SymmetricSecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:SecurityKey"])); DateTime authTime = DateTime.UtcNow; DateTime expiresAt = authTime.AddMinutes(Convert.ToDouble(_configuration["Jwt:ExpireMinutes"])); //将用户信息添加到 Claim 中 var identity = new ClaimsIdentity(JwtBearerDefaults.AuthenticationScheme); IEnumerable
claims = new Claim[] { new Claim(ClaimTypes.Name,dto.UserName), new Claim(ClaimTypes.Role,dto.Role.ToString()), new Claim(ClaimTypes.Email,dto.Email), new Claim(ClaimTypes.Expiration,expiresAt.ToString()) }; identity.AddClaims(claims); //签发一个加密后的用户信息凭证,用来标识用户的身份 _httpContextAccessor.HttpContext.SignInAsync(JwtBearerDefaults.AuthenticationScheme, new ClaimsPrincipal(identity)); var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(claims),//创建声明信息 Issuer = _configuration["Jwt:Issuer"],//Jwt token 的签发者 Audience = _configuration["Jwt:Audience"],//Jwt token 的接收者 Expires = expiresAt,//过期时间 SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256)//创建 token }; var token = tokenHandler.CreateToken(tokenDescriptor); //存储 Token 信息 var jwt = new JwtAuthorizationDto { UserId = dto.Id, Token = tokenHandler.WriteToken(token), Auths = new DateTimeOffset(authTime).ToUnixTimeSeconds(), Expires = new DateTimeOffset(expiresAt).ToUnixTimeSeconds(), Success = true }; _tokens.Add(jwt); return jwt;}

  当创建好 token 之后,客户端就可以在 Http 请求的 header 中添加 token 信息,从而访问受保护的资源。不过,在某些情况下,比如说,用户修改了密码之后,虽然当前的 token 信息可能还未过期,但我们也不能允许用户再使用当前的 token 信息进行接口的访问,这时,就涉及到了对于 token 信息的停用以及刷新。

  这里我采用是当我们停用 token 信息时,将停用的 token 信息添加到 Redis 缓存中,之后,在用户请求时判断这个 token 是不是存在于 Redis 中即可。

  当然,你也可以在停用当前用户的 token 信息时,将 HashSet 中的这个 token 信息进行删除,之后,通过判断访问时的 token 信息是否在 HashSet 集合中,判断 token 是否有效。

  方法很多,看你自己的需求了。

  对于 Redis 的读写操作,我是使用微软的 Redis 组件进行的,你可以按照你的喜好进行修改。如果你和我一样,采用这个组件,你需要在 Grapefruit.Application 这个类库中通过 Nuget 添加微软的分布式缓存抽象接口 dll 的引用,以及在 Grapefruit.WebApi 项目中添加微软的 Redis 实现。

Install-Package Microsoft.Extensions.Caching.Abstractions ## 分布式缓存抽象接口Install-Package Microsoft.Extensions.Caching.Redis ## Redis 实现

  当我们停用 token 时,通过 HttpContext 上下文获取到 HTTP Header 中的 token 信息,将该 token 信息存储到 Redis 缓存中,这样,我们就完成了对于 token 的停用。

public class JwtAppService : IJwtAppService{    ///     /// 停用 Token    ///     /// Token    /// 
public async Task DeactivateAsync(string token) => await _cache.SetStringAsync(GetKey(token), " ", new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(Convert.ToDouble(_configuration["Jwt:ExpireMinutes"])) }); /// /// 停用当前 Token /// ///
public async Task DeactivateCurrentAsync() => await DeactivateAsync(GetCurrentAsync()); /// /// 设置缓存中过期 Token 值的 key /// /// Token ///
private static string GetKey(string token) => $"deactivated token:{token}"; /// /// 获取 HTTP 请求的 Token 值 /// ///
private string GetCurrentAsync() { //http header var authorizationHeader = _httpContextAccessor .HttpContext.Request.Headers["authorization"]; //token return authorizationHeader == StringValues.Empty ? string.Empty : authorizationHeader.Single().Split(" ").Last();// bearer tokenvalue }}

  对于 token 的刷新,其实我们就可以看成重新生成了一个 token 信息,只不过我们需要将之前的 token 信息进行停用。

public class JwtAppService : IJwtAppService{    ///     /// 刷新 Token    ///     /// Token    /// 用户信息    /// 
public async Task
RefreshAsync(string token, UserDto dto) { var jwtOld = GetExistenceToken(token); if (jwtOld == null) { return new JwtAuthorizationDto() { Token = "未获取到当前 Token 信息", Success = false }; } var jwt = Create(dto); //停用修改前的 Token 信息 await DeactivateCurrentAsync(); return jwt; } ///
/// 判断是否存在当前 Token /// ///
Token ///
private JwtAuthorizationDto GetExistenceToken(string token) => _tokens.SingleOrDefault(x => x.Token == token);}

  至此,我们对于 token 的创建、刷新、停用的代码就已经完成了,接下来,我们来实现对于 token 信息的验证。PS:下面的代码如无特殊说明外,均位于 Startup 类中。

  3、鉴权

  在 ASP.NET Core 应用中,依赖注入随处可见,而我们对于我们的功能方法的使用,也是采用依赖注入到容器,通过功能接口进行调用的方式。因此,我们需要将我们的接口与其实现类注入到 IServiceCollection 容器中。这里,我们采用反射的方式,批量的将程序集内的接口与其实现类进行注入。

public void ConfigureServices(IServiceCollection services){    Assembly assembly = Assembly.Load("Grapefruit.Application");    foreach (var implement in assembly.GetTypes())    {        Type[] interfaceType = implement.GetInterfaces();        foreach (var service in interfaceType)        {            services.AddTransient(service, implement);        }    }}

  因为基础的权限验证我们是采用的微软的 JwtBearer 权限验证组件进行的授权和鉴权,因此对于 token 信息的基础鉴权操作,只需要我们在中间件中进行配置即可。同时,我们也在 IJwtAppService 接口中定义了对于 token 信息的一些操作,而对于我们自定义的权限验证策略,则需要通过基于策略的授权方式进行实现。

  首先,我们需要先定义一个继承于 IAuthorizationRequirement 的自定义授权要求类 PolicyRequirement。在这个类中,你可以定义一些属性,通过有参构造函数的方式进行构造,这里我不定义任何的属性,仅是创建这个类。

public class PolicyRequirement : IAuthorizationRequirement{ }

  当我们创建好 PolicyRequirement 这个权限要求类后,我们就可以通过继承 AuthorizationHandler 来实现我们的授权逻辑。这里实现权限控制的代码逻辑,主要是通过重写 HandleRequirementAsync 方法来实现的。

protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PolicyRequirement requirement){    //Todo:获取角色、Url 对应关系    List
list = new List
{ new Menu { Role = Guid.Empty.ToString(), Url = "/api/v1.0/Values" }, new Menu { Role=Guid.Empty.ToString(), Url="/api/v1.0/secret/deactivate" }, new Menu { Role=Guid.Empty.ToString(), Url="/api/v1.0/secret/refresh" } }; var httpContext = (context.Resource as AuthorizationFilterContext).HttpContext; //获取授权方式 var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync(); if (defaultAuthenticate != null) { //验证签发的用户信息 var result = await httpContext.AuthenticateAsync(defaultAuthenticate.Name); if (result.Succeeded) { //判断是否为已停用的 Token if (!await _jwtApp.IsCurrentActiveTokenAsync()) { context.Fail(); return; } httpContext.User = result.Principal; //判断角色与 Url 是否对应 // var url = httpContext.Request.Path.Value.ToLower(); var role = httpContext.User.Claims.Where(c => c.Type == ClaimTypes.Role).FirstOrDefault().Value; var menu = list.Where(x => x.Role.Equals(role) && x.Url.ToLower().Equals(url)).FirstOrDefault(); if (menu == null) { context.Fail(); return; } return; } } context.Fail();}

  在判断用户是否可以访问当前的请求地址时,首先需要获取到用户角色与其允许访问的地址列表,这里我使用的是模拟的数据。通过判断当前登录用户的角色是否包含请求的地址,当用户的角色并不包含对于访问地址的权限时,返回 403 Forbidden 状态码。

  这里需要注意,如果你准备采取 RESTful 风格的 API,因为请求的地址是相同的,你需要添加一个 HTTP 谓词参数用来指明所请求的方法,从而达到访问权限管控的目的。。

  包含 token 的基础验证的授权配置的代码如下所示。在中间件进行 Jwt 验证的过程中,会验证授权方式是不是 Bearer 以及通过 token 的属性解密之后与生成时用户数据进行比对,从而判断这个 token 是否有效。

public void ConfigureServices(IServiceCollection services){    string issuer = Configuration["Jwt:Issuer"];    string audience = Configuration["Jwt:Audience"];    string expire = Configuration["Jwt:ExpireMinutes"];    TimeSpan expiration = TimeSpan.FromMinutes(Convert.ToDouble(expire));    SecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["Jwt:SecurityKey"]));    services.AddAuthorization(options =>    {        //1、Definition authorization policy        options.AddPolicy("Permission",           policy => policy.Requirements.Add(new PolicyRequirement()));    }).AddAuthentication(s =>    {        //2、Authentication        s.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;        s.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;        s.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;    }).AddJwtBearer(s =>    {        //3、Use Jwt bearer         s.TokenValidationParameters = new TokenValidationParameters        {            ValidIssuer = issuer,            ValidAudience = audience,            IssuerSigningKey = key,            ClockSkew = expiration,            ValidateLifetime = true        };        s.Events = new JwtBearerEvents        {            OnAuthenticationFailed = context =>            {                //Token expired                if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))                {                    context.Response.Headers.Add("Token-Expired", "true");                }                return Task.CompletedTask;            }        };    });    //DI handler process function    services.AddSingleton
();}

  因为我们是使用 Swagger 进行的 API 文档的可视化,这里,我们继续配置 Swagger 从而使 Swagger 可以支持我们的权限验证方式。

public void ConfigureServices(IServiceCollection services){    services.AddSwaggerGen(s =>    {        //Add Jwt Authorize to http header        s.AddSecurityDefinition("Bearer", new ApiKeyScheme        {            Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"",            Name = "Authorization",//Jwt default param name            In = "header",//Jwt store address            Type = "apiKey"//Security scheme type        });        //Add authentication type        s.AddSecurityRequirement(new Dictionary
> { { "Bearer", new string[] { } } }); });}

  在停用 token 的代码中,我们使用了 Redis 去保存停用的 token 信息,因此,我们需要配置我们的 Redis 连接。

public void ConfigureServices(IServiceCollection services){    services.AddDistributedRedisCache(r =>    {        r.Configuration = Configuration["Redis:ConnectionString"];    });}

  现在,整个业务相关的代码已经完成了,我们可以创建前端访问的接口了。这里我是在 Controllers 下的 V1 文件夹下创建了一个 SecretController 用来构建前端访问的接口。控制器中主要有三个方法,分别为 CancelAccessToken(停用 token)、Login(获取 token)以及 RefreshAccessTokenAsync(刷新 token)。

public class SecretController : ControllerBase{    ///     /// 停用 Jwt 授权数据    ///     /// 
[HttpPost("deactivate")] public async Task
CancelAccessToken() { await _jwtApp.DeactivateCurrentAsync(); return Ok(); } ///
/// 获取 Jwt 授权数据 /// ///
授权用户信息 [HttpPost("token")] [AllowAnonymous] public IActionResult Login([FromBody] SecretDto dto) { //Todo:获取用户信息 var user = new UserDto { Id = Guid.NewGuid(), UserName = "yuiter", Role = Guid.Empty, Email = "yuiter@yuiter.com", Phone = "13912345678", }; if (user == null) return Ok(new JwtResponseDto { Access = "无权访问", Type = "Bearer", Profile = new Profile { Name = dto.Account, Auths = 0, Expires = 0 } }); var jwt = _jwtApp.Create(user); return Ok(new JwtResponseDto { Access = jwt.Token, Type = "Bearer", Profile = new Profile { Name = user.UserName, Auths = jwt.Auths, Expires = jwt.Expires } }); } ///
/// 刷新 Jwt 授权数据 /// ///
刷新授权用户信息 ///
[HttpPost("refresh")] public async Task
RefreshAccessTokenAsync([FromBody] SecretDto dto) { //Todo:获取用户信息 var user = new UserDto { Id = Guid.NewGuid(), UserName = "yuiter", Role = Guid.Empty, Email = "yuiter@yuiter.com", Phone = "13912345678", }; if (user == null) return Ok(new JwtResponseDto { Access = "无权访问", Type = "Bearer", Profile = new Profile { Name = dto.Account, Auths = 0, Expires = 0 } }); var jwt = await _jwtApp.RefreshAsync(dto.Token, user); return Ok(new JwtResponseDto { Access = jwt.Token, Type = "Bearer", Profile = new Profile { Name = user.UserName, Auths = jwt.Success ? jwt.Auths : 0, Expires = jwt.Success ? jwt.Expires : 0 } }); }}

  现在,让我们测试一下,从下图中可以看到,当我们未获取 token 时,访问接口提示我们 401 Unauthorized,当我们模拟登录获取到 token 信息后,再次访问受保护的资源时,已经可以获取到响应的数据。之后,当我们刷新 token,此时再用原来的 token 信息访问时,已经无法访问,提示 403 Forbidden,同时,可以看到我们的 Redis 中已经存在了停用的 token 信息,此时,使用新的 token 信息又可以访问了。

  至此,整个的 Jwt 授权鉴权相关的代码就已经完成了,因为篇幅原因,完整的代码请到 Github 上进行查看()。PS:因为博客园允许上传的图片限制最大尺寸为 10M,所以这里上传的 gif 是压缩后的,见谅见谅,如果有需要查看清晰的图片,欢迎到我的个人博客上查看()。

 三、总结

   本章,主要是使用 Jwt 完成对于用户的授权与鉴权,实现了对于用户 token 令牌的创建、刷新、停用以及校验。在实际的开发中,采用成熟的轮子可能是更好的方案,如果你有针对 Jwt 进行用户授权、鉴权更好的解决方案的话,欢迎你在评论区留言指出。拖了很久,应该是年前的最后一篇了,提前祝大家新年快乐哈~~~

转载于:https://www.cnblogs.com/danvic712/p/10331976.html

你可能感兴趣的文章
HDU1051 Wooden Sticks 【贪婪】
查看>>
十大经典数据挖掘算法
查看>>
Rhythmbox乱码的解决的方法
查看>>
中纪委:抗震中官员临危退缩玩忽职守将被严处
查看>>
MySQL 8.0.12 基于Windows 安装教程
查看>>
在hue中使用hive
查看>>
eclipse快捷键
查看>>
在指定文本里记录内容
查看>>
Android WebView常见问题及解决方案汇总
查看>>
[BZOJ4025]二分图
查看>>
HTML5 Canvas玩转酷炫大波浪进度图
查看>>
创建ASP.NET Core MVC应用程序(5)-添加查询功能 & 新字段
查看>>
电话录音系统说明书
查看>>
JVM(1)——IDEA启动分配内存大小及GC日志打印
查看>>
oracle 批量更新之update case when then
查看>>
text3
查看>>
自己写的连击文字特效
查看>>
【Android】eclipse打不开的解决办法和“Jar mismatch! Fix your dependencies”的解决
查看>>
Mysql查询某字段值重复的数据
查看>>
Java 自学笔记-基本语法3setOut()方法设置新的输出流
查看>>