写在前面 一般来说,access_token
令牌肯定不会像之前那样存在内存中,通常我们都会将其存入Redis数据库中。在实际开发过程中,不仅仅是令牌,对于客户端信息,我们同样需要将其存入数据库中,还有对于第三方应用而言,前面的配置都显得过于臃肿,因此接下来就尝试对上述一些问题进行细致分析。
项目初始化 同样由于授权码模式的完整性,这里依旧选择使用授权码模式进行分析。授权码模式中各实例项目名称、角色名称和端口如下表所示:
项目名称
角色名称
端口
auth-server
授权服务器
8080
user-server
资源服务器
8081
client-app
客户端(第三方应用)
8082
空Maven父工程搭建 使用Maven新建一个空白的父工程,名称为authorization-code-redis
,之后我们将在这个父工程中搭建子项目。
授权服务器搭建 在authorization-code-redis
父工程中新建一个子模块,名称为auth-server
,在选择依赖的时候选择如下五个依赖:Web、Spring Cloud Security、Spring Cloud OAuth2、Redis、MySQL依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-oauth2</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> </exclusion> </exclusions> </dependency> </dependencies>
可以看到既然是需要将令牌存入Redis,客户端信息存入MySQL,那么就需要添加对应的依赖。第一步 ,将父工程authorization-code-redis
项目 的pom.xml依赖文件修改为如下所示配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.envy</groupId> <artifactId>authorizationcoderedis</artifactId> <version>1.0-SNAPSHOT</version> <name>authorization-code-redis</name> <description>OAuth2.0令牌持久化</description> <packaging>pom</packaging> <properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> </properties> <modules> <module>auth-server</module> </modules> </project>
第二步 ,回到子模块auth-server
中,在其项目目录下新建一个config包,之后在该类中新建一个SecurityConfig
类,注意这个类需要继承WebSecurityConfigurerAdapter
类,里面的代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(10); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("envy").password(new BCryptPasswordEncoder().encode("1234")).roles("admin") .and() .withUser("hello").password(new BCryptPasswordEncoder().encode("1234")).roles("user"); } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests().anyRequest().authenticated() .and().formLogin() .and().csrf().disable(); } }
注意由于本系列笔记主要是学习如何使用OAuth2.0,因此不会详细介绍Spring Security的相关知识。这里出于简单起见,并没有将用户存入数据库中,而是直接存在内存中。此处首先提供了一个密码加密PasswordEncoder类的实例,之后在其中定义了两个用户,并定义了他们的用户名、密码和对应角色。接着还使用了系统默认的登录表单,这样便于后续用户登录。举个例子,开发者想让微信登录第三方网站,那么这有一个前提就是得让用户先登录微信,而登录微信需要用户名和密码,因此此处配置的其实就是用户登录所需的信息。
Redis存储令牌 完成了用户的基本信息配置后,接下来开始配置授权服务器。在前面授权码模式一文中,对于授权码我们使用InMemoryAuthorizationCodeServices
存储在内存中;对于令牌我们使用InMemoryTokenStore
存储在内存中。对于授权码来说,使用一次之后就会失效,因此将其存在内存中是可以的,但是令牌一般都是有使用期限的,在该期限内是可以多次使用的,因此将其也存在内存中并不是最佳的方式。
TokenStore
用于将生成的token存放在何处,这是一个接口,查看该接口的实现类:
可以看到它提供了多种方式来存储令牌: (1)InMemoryTokenStore
,这是系统默认的存储方式。顾名思义就是将令牌access_token
存入内存中,在单体应用中是没有任何问题,但是在分布式环境下就会出现很多问题。 (2)JdbcTokenStore
,看名字就知道这种方式是将令牌access_token
存入数据库中,这样便于在多应用之间共享令牌数据。 (3)JwkTokenStore
,看名字就知道这种方式是将令牌access_token
存入JSON Web Key中。 (4)JwtTokenStore
,请注意,这个其实并不是存储,因为使用jwt之后,生成的jwt中就存有用户的所有信息。因为服务端是不用保存令牌,也就是无状态登录。 (5)RedisTokenStore
,看名字就知道这种方式是将令牌access_token
存入Redis中。
尽管系统提供了5种存储令牌的方式,但是在实际开发过程中用的多的还是RedisTokenStore
和JwtTokenStore
这两种。JwtTokenStore
这种方式非常复杂,后续会进行学习。这里主要学习使用RedisTokenStore
来存储令牌。
第三步 ,在application.properties
配置文件中添加Redis配置信息:
1 2 3 4 5 6 server.port=8080 spring.redis.host=192.168.59.100 spring.redis.port=6371 spring.redis.database=0 spring.redis.password=envy123
第四步 ,在config包内新建一个AccessTokenConfig
类,其中的代码如下所示:
1 2 3 4 5 6 7 8 9 10 @Configuration public class AccessTokenConfig { @Autowired private RedisConnectionFactory redisConnectionFactory; @Bean TokenStore tokenStore(){ return new RedisTokenStore(redisConnectionFactory); } }
可以看到这里我们提供一个TokenStore
实例,准确来说是RedisTokenStore
实例,该实例表示将生成的token存放在Redis中。
客户端信息入库 在前面我们都是使用了类似于下面的代码,将客户端信息存储在了内存中:
1 2 3 4 5 6 7 @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("envythink").secret(new BCryptPasswordEncoder().encode("1234")) .resourceIds("res1").authorizedGrantTypes("authorization_code","refresh_token") .scopes("all").redirectUris("http://127.0.0.1:8082/index.html"); }
但是在实际工作中绝不会这么操作,一是这里直接将客户端信息在代码中固定化了,不利于后续维护;二是客户端信息是比较多的,直接将其存储在内存中会占用大量的内存资源,这是极为不可取的,因此正确的做法是将其存储在数据库中。
与用户相关的UserDetailsService接口相类似,客户端信息主要与ClientDetailsService接口相关。查看一下该接口的实现类:
可以看到它有两个实现类,分别是InMemoryClientDetailsService
和JdbcClientDetailsService
。显然前者用于将数据存入内存中,后者用于存入数据库中。查看一下JdbcClientDetailsService
的源码,可以发现它很长,这里主要关注它无参数的构造方法:
1 2 3 4 5 6 7 8 9 10 public JdbcClientDetailsService(DataSource dataSource) { this.updateClientDetailsSql = DEFAULT_UPDATE_STATEMENT; this.updateClientSecretSql = "update oauth_client_details set client_secret = ? where client_id = ?"; this.insertClientDetailsSql = "insert into oauth_client_details (client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove, client_id) values (?,?,?,?,?,?,?,?,?,?,?)"; this.selectClientDetailsSql = "select client_id, client_secret, resource_ids, scope, authorized_grant_types, web_server_redirect_uri, authorities, access_token_validity, refresh_token_validity, additional_information, autoapprove from oauth_client_details where client_id = ?"; this.passwordEncoder = NoOpPasswordEncoder.getInstance(); Assert.notNull(dataSource, "DataSource required"); this.jdbcTemplate = new JdbcTemplate(dataSource); this.listFactory = new DefaultJdbcListFactory(new NamedParameterJdbcTemplate(this.jdbcTemplate)); }
可以看到这里面其实就是对系统自带的oauth_client_details
表进行了增改查操作,因此我们完全可以从这个方法中推断出这个oauth_client_details
表的创建语法,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 DROP TABLE IF EXISTS `oauth_client_details`; CREATE TABLE `oauth_client_details`( `client_id` varchar(48) NOT NULL, `client_secret` varchar(256) DEFAULT NULL, `resource_ids` varchar(256) DEFAULT NULL, `scope` varchar(256) DEFAULT NULL, `authorized_grant_types` varchar(256) DEFAULT NULL, `web_server_redirect_uri` varchar(256) DEFAULT NULL, `authorities` varchar(256) DEFAULT NULL, `access_token_validity` int(11) DEFAULT NULL, `refresh_token_validity` int(11) DEFAULT NULL, `additional_information` varchar(4096) DEFAULT NULL, `autoapprove` varchar(256) DEFAULT NULL, PRIMARY KEY(`client_id`) )ENGINE=INNODB CHARSET=utf8;
第五步 ,新建一个名为oauth2的数据库,之后运行上述创建数据表的SQL。
第六步 ,在application.properties
配置文件中添加MySQL配置信息:
1 2 3 4 5 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.username=root spring.datasource.password=root spring.datasource.url=jdbc:mysql:///oauth2?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai spring.main.allow-bean-definition-overriding=true
注意最后需要添加spring.main.allow-bean-definition-overriding=true
这一行代码,因为我们既然是自定义ClientDetailsService,那么就需要设置允许将自定义的实例覆盖系统默认的实例。
第七步 ,在config包内新建一个AuthorizationServerConfig
类,注意这个类需要继承AuthorizationServerConfigurerAdapter
类,里面的代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 @Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private TokenStore tokenStore; @Autowired private DataSource dataSource; @Bean ClientDetailsService clientDetailsService(){ return new JdbcClientDetailsService(dataSource); } @Bean AuthorizationServerTokenServices tokenServices(){ DefaultTokenServices services = new DefaultTokenServices(); services.setClientDetailsService(clientDetailsService()); services.setSupportRefreshToken(true); services.setTokenStore(tokenStore); services.setAccessTokenValiditySeconds(60*60*2); services.setRefreshTokenValiditySeconds(60*60*24*3); return services; } @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { security.checkTokenAccess("permitAll()") .allowFormAuthenticationForClients(); } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.withClientDetails(clientDetailsService()); } //授权码依旧存在内存中 @Bean AuthorizationCodeServices authorizationCodeServices(){ return new InMemoryAuthorizationCodeServices(); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.authorizationCodeServices(authorizationCodeServices()).tokenServices(tokenServices()); } }
接下来对上述代码进行分析: (1)自定义AuthorizationServerConfig
类,并继承AuthorizationServerConfigurerAdapter
类,用来对授权服务器做更为详细的配置。请注意,此时需要在该类上添加@EnableAuthorizationServer
注解,表示开启授权服务器的自动化配置。 (2)在自定义的AuthorizationServerConfig
类中重写三个方法,这些方法分别用于对令牌端点安全、客户端信息、令牌访问端点和服务等内容进行详细配置。 (3)重写configure(AuthorizationServerSecurityConfigurer security)
方法,该方法用于配置令牌端点的安全约束,即这个端点谁能访问,谁不能访问。接着我们调用checkTokenAccess()
方法,里面设置值为permitAll()
,表示该端点可以直接访问(后面当资源服务器收到token之后,需要校验token是否合法,此时就会访问这个端点)。查看源码可以知道该值默认为denyAll()
表示都拒绝。 (4)重写configure(ClientDetailsServiceConfigurer clients)
方法,该方法用于配置客户端的详细信息。在前面我们说过,授权服务器会进行两个方面的校验,一是校验客户端;而是校验用户。我们知道Spring Security与用户存储相关的类是UserDetailsService
,由于我们将用户直接保持在内存中,因此系统默认就会通过这个类去校验用户。那么接下来就是校验客户端,需要注入ClientDetailsService
对象。这里其实就是配置客户端的信息,我们将其配置在MySQL数据库中。 (5)重写configure(AuthorizationServerEndpointsConfigurer endpoints)
方法,该方法用于配置令牌的访问端点和令牌服务。首先调用authorizationCodeServices
方法来配置授权码的存储位置,由于我们是存储在内存中,因此需要提供一个返回AuthorizationCodeServices
实例的方法,当然了更准确的说我们是返回一个InMemoryAuthorizationCodeServices
对象。接着调用tokenServices()
方法,来配置token的存储位置。授权码(authorization_code
)和令牌(token
)是有区别的,授权码用于获取令牌,使用一次就会失效;而令牌则用来获取资源。 (6)请注意,前面我们定义了tokenStore()
方法,该方法仅仅是配置了token的存储位置,但是对于token并没有进行设置。接下来需要提供一个AuthorizationServerTokenServices
实例,开发者可以在该实例中配置token的基本信息,如token是否支持刷新、token的存储位置、token的过期时间以及刷新token的有效期。所谓的“刷新token的有效期”是指当token快要过期的时候,我们肯定是需要获取一个新的token,而在获取新的token的时候,需要有一个凭证信息,注意这个凭证信息不是旧的token,而是另外一个refresh_token
,而这个refresh_token
也是有有效期的。
第四步 ,在项目application.properties
配置文件中新增如下用于配置项目端口的代码:
以上就是授权服务器的搭建工作,那么接下来就启动该授权服务器。
资源服务器搭建 请注意此处资源服务器搭建和授权码模式时的步骤完全一样,这里将之前搭建的过程给搬运过来而已。
在完成了授权服务器的搭建工作之后,接下来开始搭建资源服务器。如果用户的项目属于中小型时,那么通常都会将资源服务器和授权服务器放在一起,但是如果是大型项目,那么都会将两者进行分离。因此本篇就假设用户正在开发的是大型项目,就将两者进行分离。
资源服务器,顾名思义就是用来存放用户的资源,这里就是用户的基本信息,如使用微信登录,那么就可能是头像、姓名、openid等信息。用户从授权服务器上获取到access_token
之后,接着就会通过access_token
去资源服务器上获取数据。
第一步 ,在authorization-code-redis
父工程中新建一个子模块,名称为user-server
,在选择依赖的时候选择如下三个依赖:Web、Spring Cloud Security和Spring Cloud OAuth2依赖:
第二步 ,在父工程authorization-code-redis
项目的pom.xml依赖文件中新增如下所示配置:
1 2 3 4 <modules> <module>auth-server</module> <module>user-server</module> </modules>
第三步 ,回到子模块user-server
中,在其项目目录下新建一个config包,之后在该类中新建一个ResourceServerConfig
类,注意这个类需要继承ResourceServerConfigurerAdapter
类,里面的代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Bean RemoteTokenServices tokenServices(){ RemoteTokenServices services = new RemoteTokenServices(); services.setCheckTokenEndpointUrl("http://127.0.0.1:8080/oauth/check_token"); services.setClientId("envythink"); services.setClientSecret("1234"); return services; } @Override public void configure(ResourceServerSecurityConfigurer resources) throws Exception { resources.resourceId("res1").tokenServices(tokenServices()); } @Override public void configure(HttpSecurity http) throws Exception { http.authorizeRequests().antMatchers("/admin/**").hasRole("admin") .anyRequest().authenticated(); } }
接下来对上述代码进行分析: (1)自定义ResourceServerConfig
类,并继承ResourceServerConfigurerAdapter
类,用来对资源服务器做更为详细的配置。请注意,此时需要在该类上添加@EnableResourceServer
注解,表示开启资源服务器的自动化配置。 (2)在自定义的ResourceServerConfig
类中重写两个方法,这些方法分别用于对资源信息和页面访问等内容进行详细配置。 (3)重写configure(ResourceServerSecurityConfigurer resources)
方法,该方法用于对资源进行配置。这里配置了资源的id,同时调用tokenServices()
来配置token的存储位置。由于此处将资源服务器和授权服务器进行了分离,因此需要提供一个RemoteTokenServices
对象,言外之意就是当资源服务器和授权服务器放在一起时,就不必提供一个RemoteTokenServices
对象。 (4)我们定义了一个tokenServices()
方法,该方法用于返回(3)中所需要的RemoteTokenServices
对象,在RemoteTokenServices
对象中,我们配置了access_token
的的校验地址、客户端id,客户端秘钥等,其实这就是授权服务器的地址信息。这样当用户来资源服务器请求资源时,会携带一个access_token
,通过此处的配置,它就能检验这个token是否正确。 (5)重写configure(HttpSecurity http)
方法,该方法用于对访问页面进行配置。其实就是对资源进行拦截,这里就是判断当用户访问URL是以/admin
开头的时候,需要用户具备admin角色才能访问。第四步 ,既然是资源服务器,那么我们就需要提供资源,这里提供两个接口。在项目目录下新建一个controller包,之后在该类中新建一个HelloController
类,里面的代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 @RestController public class HelloController { @GetMapping("/hello") public String hello(){ return "hello"; } @GetMapping("admin/hello") public String admin(){ return "admin"; } }
第五步 ,在项目application.properties
配置文件中新增如下用于配置项目端口的代码:
以上就是资源服务器的搭建工作,那么接下来就启动该资源服务器。
客户端(第三方应用)优化 请注意,客户端(第三方应用)并非必须的,开发者可以使用诸如Postman等测试工具来进行测试。此处为了案例的完整性,依旧搭建了一个普通的Spring Boot项目。
第一步 ,在authorization-code-redis
父工程中新建一个子模块,名称为client-app
,在选择依赖的时候选择如下两个依赖:Web和Thymeleaf依赖:
第二步 ,在父工程authorization-code-redis
项目的pom.xml依赖文件中新增如下所示配置:
1 2 3 4 5 <modules> <module>auth-server</module> <module>user-server</module> <module>client-app</module> </modules>
第三步 ,回到子模块client-app
中,在其resources/templates
目录下新建一个index.html
文件,其中的代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 <!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>首页</title> </head> <body> <h1>欢迎来到OAth2.0 授权码模式实例优化</h1> <a href="http://127.0.0.1:8080/oauth/authorize?client_id=envythink&response_type=code&scope=all&redirect_uri=http://127.0.0.1:8082/index.html">第三方登录</a> <h1 th:text="${msg}"></h1> </body> </html>
该页面的意思是点击超链接,就可以实现第三方登录,超链接中的参数如下所示: (1)client_id
表示客户端id,这个需要开发者根据授权服务器中的实际配置来进行设置。 (2)response_type
表示响应类型,此处设置为code,表示响应一个授权码。 (3)redirect_uri
表示授权成功后的重定向地址,这里设置了跳转到第三方应用的首页。 (4)scop
表示授权的范围,此处值为all。 可以看到上述html页面中还存在一个h1标签,注意该标签中的数据来源于资源服务器。当授权服务器通过后,我们就可以拿着access_token
去资源服务器上请求数据,去加载资源,这样加载到的数据就会在h1标签中显示出来。第四步 ,回到子模块client-app
中,在其项目目录下新建一个controller包,并在该包内新建一个HelloController
类,其中的代码如下所示:
1 2 3 4 5 6 7 8 9 10 @Controller public class HelloController { @Autowired TokenTask tokenTask; @GetMapping("/index.html") public String hello(String code, Model model) { model.addAttribute("msg", tokenTask.getData(code)); return "index"; } }
我们在此处的HelloController类中定义了一个hello方法,该方法用于访问/index.html
接口。可以看到此时我们是通过调用tokenTask.getData()
方法来获取对应的信息。
接着我们新建一个component包,并在该包内新建一个TokenTask类,其中的代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 @Component public class TokenTask { @Autowired private RestTemplate restTemplate; public String access_token = ""; public String refresh_token = ""; public String getData(String code) { if ("".equals(access_token) && code != null) { MultiValueMap<String, String> map = new LinkedMultiValueMap<>(); map.add("code", code); map.add("client_id", "envythink"); map.add("client_secret", "1234"); map.add("redirect_uri", "http://127.0.0.1:8082/index.html"); map.add("grant_type", "authorization_code"); Map<String, String> resp = restTemplate.postForObject("http://127.0.0.1:8080/oauth/token", map, Map.class); access_token = resp.get("access_token"); refresh_token = resp.get("refresh_token"); return loadDataFromResServer(); } else { return loadDataFromResServer(); } } private String loadDataFromResServer() { try { HttpHeaders headers = new HttpHeaders(); headers.add("Authorization", "Bearer " + access_token); HttpEntity<Object> httpEntity = new HttpEntity<>(headers); ResponseEntity<String> entity = restTemplate.exchange("http://127.0.0.1:8081/admin/hello", HttpMethod.GET, httpEntity, String.class); return entity.getBody(); } catch (RestClientException e) { return "未加载"; } } @Scheduled(cron = "0 55 0/1 * * ?") public void tokenTask() { MultiValueMap<String, String> map = new LinkedMultiValueMap<>(); map.add("client_id", "envythink"); map.add("client_secret", "1234"); map.add("refresh_token", refresh_token); map.add("grant_type", "refresh_token"); Map<String, String> resp = restTemplate.postForObject("http://127.0.0.1:8080/oauth/token", map, Map.class); access_token = resp.get("access_token"); refresh_token = resp.get("refresh_token"); } }
接下来对上述代码进行分析,在getData()
方法中,当access_token
为空字符串且code不为空时,说明此时是刚拿到授权码的时候,准备去申请令牌了。在拿到令牌之后,将access_token
和refresh_token
分别赋值给全局变量,之后调用loadDataFromResServer()
方法去资源服务器请求资源。
除此之外,这里还定义了一个tokenTask()
方法,这是一个定时任务,每隔115分钟去刷新access_token
,注意笔者将access_token
的有效期设置为120分钟了。
之后就是启动项目进行测试了,由于这个过程较为简单,因此这里就直接跳过。
(完)