带有IoC Starter的Web App。 使用IOC上下文,IOC Web和IOC ORM的基本查询映射

图片

引言


自从发布第一个版本以来,已经花费了很多时间( 链接到上一篇文章 )。 有什么变化?


  • 改善整个系统的稳定性;
  • 实现延迟组件加载;
  • 内置基本监听器系统
  • 对面向方面的编程的内置支持(用于中等难度的解决问题,否则,我建议您将AspectJ库用于其余部分)
  • 新的RequestFactory引导程序
  • 与基于EhCache,Guava的缓存的集成工作
  • 集成了使用流的功能(通过@SimpleTask批注进行初始化并直接使用池)

**模块


  • 用于数据库的模块(轻量级ORM,支持JPA,事务,NO-SQL驱动程序-Orient,Crud方法,存储库系统以及根据存储库类的功能自动生成的查询)
  • 基于Netty 4.1.30的Web枪口模块(通过注释的链接映射,对自定义生产者/消费的支持,速度模板渲染页面,基本安全请求,会话,Cookie,SSL)

框架结构
结构


您说,“这当然很好,但实际上这一切都可行吗?”
“是的,它有效。我要求减薪。”


实施过程示例


为了实现该示例,我将使用Maven 3和Intelijj Idea 2018.2。


1)连接依赖项:


<?xml version="1.0" encoding="UTF-8"?> <project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0" 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> <artifactId>example-webapp</artifactId> <groupId>org.ioc</groupId> <packaging>jar</packaging> <version>0.0.1</version> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.0</version> <configuration> <.source>1.8</source> <target>1.8</target> </configuration> <executions> <execution> <id>default-testCompile</id> <phase>test-compile</phase> <goals> <goal>testCompile</goal> </goals> </execution> </executions> </plugin> </plugins> </build> <dependencies> <dependency> <groupId>org.ioc</groupId> <artifactId>context-factory</artifactId> <version>2.2.4.STABLE</version> </dependency> <dependency> <groupId>org.ioc</groupId> <artifactId>orm-factory</artifactId> <version>2.2.4.STABLE</version> </dependency> <dependency> <groupId>org.ioc</groupId> <artifactId>web-factory</artifactId> <version>2.2.4.STABLE</version> </dependency> </dependencies> <repositories> <repository> <id>context</id> <url>https://raw.github.com/GenCloud/ioc_container/context</url> </repository> <repository> <id>cache</id> <url>https://raw.github.com/GenCloud/ioc_container/cache</url> </repository> <repository> <id>threading</id> <url>https://raw.github.com/GenCloud/ioc_container/threading</url> </repository> <repository> <id>orm</id> <url>https://raw.github.com/GenCloud/ioc_container/orm</url> </repository> <repository> <id>web</id> <url>https://raw.github.com/GenCloud/ioc_container/web</url> </repository> </repositories> </project> 

**他还没有搬到Maven Central,a。


项目结构:
结构
标准的MVC模式,不是吗?


创建应用程序的入口点:


 package org.examples.webapp; import org.ioc.annotations.context.ScanPackage; import org.ioc.annotations.modules.CacheModule; import org.ioc.annotations.modules.DatabaseModule; import org.ioc.annotations.modules.ThreadingModule; import org.ioc.annotations.modules.WebModule; import org.ioc.context.starter.IoCStarter; @WebModule @CacheModule @ThreadingModule @DatabaseModule @ScanPackage(packages = {"org.examples.webapp"}) public class AppMain { public static void main(String[] args) { IoCStarter.start(AppMain.class); } } 

**说明:
注释@ScanPackages-定义用于标识组件的上下文程序包(在普通人中为“ 容器 ”)。
注释@WebModule-用于连接和初始化Web工厂。
注释@CacheModule-用于连接和初始化缓存工厂,用于ORM正常工作(在将来的版本中,将不需要注释)。
注释@ThreadingModule-用于连接和初始化线程工厂;用于Web工厂正常工作(在将来的版本中,将不需要注释)。
注释@DatabaseModule-用于连接和初始化ORM工厂。
所有工厂都有默认配置程序,您可以通过重新定义工厂使用的设置功能来更改为自己的配置程序(在每个模块注释中,重新定义了类配置程序-Class <?> AutoConfigurationClass()default WebAutoConfiguration.class ),或使用main中的@Exclude注释禁用任何配置教室。
IoCStarter实用程序是主要的上下文初始化器类。


好了,一切似乎都准备就绪,上下文已初始化,网络在默认端口8081上运行,但是没有链接,当您访问该站点时,它实际上并没有提供任何帮助。 好吧,继续前进。


让我们为我们的模块创建一个配置文件。 默认情况下,所有配置都是从{working_dir} /configs/default_settings.properties加载的-我们将沿着适当的路径创建它。


 # Threading ioc.threads.poolName=shared ioc.threads.availableProcessors=4 ioc.threads.threadTimeout=0 ioc.threads.threadAllowCoreTimeOut=true ioc.threads.threadPoolPriority=NORMAL # Event dispather # -  ()    ( ) ioc.dispatcher.availableDescriptors=4 # Cache #   (EhFactory|GuavaFactory) cache.factory=org.ioc.cache.impl.EhFactory # Datasource #   (-, -  ) #LOCAL, LOCAL_SERVER, REMOTE datasource.orient.database-type=LOCAL #   datasource.orient.url=./database #    (   ) datasource.orient.database=orient #   datasource.orient.username=admin #   datasource.orient.password=admin #       (create, dropCreate, refresh, none) datasource.orient.ddl-auto=dropCreate #   ,      datasource.orient.showSql=true # Web server #     web.server.port=8081 #   SSL  web.server.ssl-enabled=false # in seconds #   ( 7200 . = 2 ) web.server.security.session.timeout=300 #  - web.server.velocity.input.encoding=UTF-8 web.server.velocity.output.encoding=UTF-8 #  - web.server.velocity.resource.loader=file #   web.server.velocity.resource.loader.class=org.apache.velocity.runtime.resource.loader.FileResourceLoader #    - web.server.velocity.resource.loading.path=./public 

接下来,我们需要一个用户实体及其管理存储库:
TblAccount实体的实现:


 package org.examples.webapp.domain.entity; import org.ioc.web.security.user.UserDetails; import javax.persistence.*; import java.util.Collections; import java.util.List; @Entity public class TblAccount implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) private long id; @Column(name = "username") private String username; @Column(name = "password") private String password; @Transient private String repeatedPassword; public String getRepeatedPassword() { return repeatedPassword; } public void setRepeatedPassword(String repeatedPassword) { this.repeatedPassword = repeatedPassword; } @Override public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } @Override public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } @Override public List<String> getRoles() { return Collections.singletonList("ROLE_USER"); } } 

**说明:
一切都是与所有JPA支持框架一样的标准。 使用@ .Entity映射实体,使用@ .Id注释创建主键,使用@Column注释映射列 并从UserDetails继承以标识Security模块中的实体。


TblAccountRepository实体存储库的实现:


 package org.examples.webapp.domain.repository; import org.examples.webapp.domain.entity.TblAccount; import org.ioc.annotations.context.IoCRepository; import org.ioc.orm.repositories.CrudRepository; import javax.transaction.Transactional; @IoCRepository public interface TblAccountRepository extends CrudRepository<TblAccount, Long> { @Transactional TblAccount findByUsernameEq(String username); } 

**说明:
注释@IoCRepository-用于根据上下文确定该类是存储库,并且需要“不同地”对其进行处理。
支持标准的CRUD功能:


  • 实体提取(ID id) -从数据库或通过主键从缓存中获取Entity类型的实体;
  • 列表fetchAll()-获取所有Entity类型的实体,并将它们预加载到缓存中;
  • void save(实体实体) -在数据库和缓存中创建/更新Entity类型的实体;
  • void delete(实体实体) -从基础和缓存中都删除Entity类型的实体;
  • 布尔值存在(ID ID) -使用主键检查数据库中是否存在实体。
    所有CRUD请求都在事务中发生。

它支持通过使用关键字覆盖函数来自动生成查询,如上述实现( TblAccount findByUsernameEq(String username) )和调用注册查询( NamedQuery


函数findByUsernameEq(字符串用户名) -通过用户名字段搜索实体。 产生的要求:


  select * from tbl_account where username = 'username' 

接下来,我们需要一个用于管理业务逻辑的级别。
AccountService实现:


 package org.examples.webapp.service; import org.examples.webapp.domain.entity.TblAccount; import org.examples.webapp.domain.repository.TblAccountRepository; import org.examples.webapp.responces.IMessage; import org.ioc.annotations.context.IoCComponent; import org.ioc.annotations.context.IoCDependency; import org.ioc.web.model.http.Request; import org.ioc.web.security.configuration.SecurityConfigureAdapter; import org.ioc.web.security.encoder.bcrypt.BCryptEncoder; import org.ioc.web.security.user.UserDetails; import org.ioc.web.security.user.UserDetailsProcessor; import java.util.Objects; import static org.examples.webapp.responces.IMessage.Type.ERROR; import static org.examples.webapp.responces.IMessage.Type.OK; @IoCComponent public class AccountService implements UserDetailsProcessor { @IoCDependency private TblAccountRepository tblAccountRepository; @IoCDependency private BCryptEncoder bCryptEncoder; @IoCDependency private SecurityConfigureAdapter securityConfigureAdapter; @Override public UserDetails loadUserByUsername(String username) { return tblAccountRepository.findByUsernameEq(username); } public void save(TblAccount tblAccount) { tblAccountRepository.save(tblAccount); } public void delete(TblAccount tblAccount) { tblAccountRepository.delete(tblAccount); } public IMessage tryCreateUser(String username, String password, String repeatedPassword) { if (username == null || username.isEmpty() || password == null || password.isEmpty() || repeatedPassword == null || repeatedPassword.isEmpty()) { return new IMessage(ERROR, "Invalid request parameters!"); } if (!Objects.equals(password, repeatedPassword)) { return new IMessage(ERROR, "Repeated password doesn't match!"); } final UserDetails userDetails = loadUserByUsername(username); if (userDetails != null) { return new IMessage(ERROR, "Account already exists!"); } final TblAccount account = new TblAccount(); account.setUsername(username); account.setPassword(bCryptEncoder.encode(password)); save(account); return new IMessage(OK, "Successfully created!"); } public IMessage tryAuthenticateUser(Request request, String username, String password) { if (username == null || username.isEmpty() || password == null || password.isEmpty()) { return new IMessage(ERROR, "Invalid request parameters!"); } final UserDetails userDetails = loadUserByUsername(username); if (userDetails == null) { return new IMessage(ERROR, "Account not found!"); } if (!bCryptEncoder.match(password, userDetails.getPassword())) { return new IMessage(ERROR, "Password does not match!"); } securityConfigureAdapter.getContext().authenticate(request, userDetails); return new IMessage(OK, "Successfully authenticated"); } public IMessage logout(Request request) { if (securityConfigureAdapter.getContext().removeAuthInformation(request)) { return new IMessage(OK, "/"); } return new IMessage(ERROR, "Credentials not found or not authenticated!"); } } 

**说明:
注释@IoCComponent-用于将类初始化为组件。
注释@IoCDependency-用于依赖项注入到类实例中。
BCryptEncoder实用程序是用于密码加密的BCrypt编解码器的实现(到目前为止是唯一的编解码器)。
系统实例SecurityConfigureAdapter-用于处理映射请求和用户会话。
函数UserDetails loadUserByUsername-继承的函数UserDetailsProcessor ,用于将用户加载到会话中并设置身份验证标志(将来用于Security中授权的标准映射)
IMessage函数tryCreateUser是用户创建函数。
IMessage函数tryAuthenticateUser-用户身份验证功能。
IMessage注销功能是用于清除授权用户的会话的功能。
IMessage类是一个实用程序类,用于在浏览器中显示我们所需的信息(json响应)。


 package org.examples.webapp.responces; public class IMessage { private final String message; private final Type type; public IMessage(String message) { this.message = message; type = Type.OK; } public IMessage(Type type, String message) { this.message = message; this.type = type; } public String getMessage() { return message; } public Type getType() { return type; } public enum Type { OK, ERROR } } 

现在,您需要实现链接本身(请求映射):


 package org.examples.webapp.mapping; import org.examples.webapp.domain.entity.TblAccount; import org.examples.webapp.responces.IMessage; import org.examples.webapp.service.AccountService; import org.ioc.annotations.context.IoCDependency; import org.ioc.annotations.web.IoCController; import org.ioc.web.annotations.Credentials; import org.ioc.web.annotations.MappingMethod; import org.ioc.web.annotations.RequestParam; import org.ioc.web.annotations.UrlMapping; import org.ioc.web.model.ModelAndView; import org.ioc.web.model.http.Request; @IoCController @UrlMapping("/") public class MainMapping { @IoCDependency private AccountService accountService; @UrlMapping public ModelAndView index() { final ModelAndView modelAndView = new ModelAndView(); modelAndView.setView("index"); return modelAndView; } @UrlMapping(value = "signup", method = MappingMethod.POST) public IMessage createUser(@RequestParam("username") String username, @RequestParam("password") String password, @RequestParam("repeatedPassword") String repeatedPassword) { return accountService.tryCreateUser(username, password, repeatedPassword); } @UrlMapping(value = "signin", method = MappingMethod.POST) public IMessage auth(Request request, @RequestParam("username") String username, @RequestParam("password") String password) { return accountService.tryAuthenticateUser(request, username, password); } @UrlMapping("signout") public IMessage signout(Request request) { return accountService.logout(request); } @UrlMapping("loginPage") public ModelAndView authenticated(@Credentials TblAccount account) { final ModelAndView modelAndView = new ModelAndView(); modelAndView.setView("auth"); modelAndView.addAttribute("account", account); return modelAndView; } } 

**说明:
注释@IoCController-用于将上下文中的类标识为控制器(浏览器请求映射器)
注释@UrlMapping-指示有必要分析函数/类以查看是否存在由通道处理程序处理的请求。
参数:


  • 价值 -我们需要的要求;
  • method-用于处理的HTTP方法(GET,POST,PUT等);
  • 消费 -http mime类型以检查是否存在特定类型的请求(可选);
  • 产生 -http内容类型以在响应中返回特定的内容类型(内容类型:text / html;字符集= utf-8;内容类型:multipart / form-data;边界=某物,等等;可选;

注释@RequestParam-用于确定从请求中接收到的参数的名称。 由于默认情况下无法通过反射方式获得method参数的当前名称,因此我懒于将额外的javaassist依赖项与asm的萨满连接。 因此,这种确定用于嵌入从请求获得的参数值的参数名称的方法。 GET类型有一个类似物- @PathVariable ,其操作原理相同(与POST不兼容)。
@Credentials注释 -用于插入授权用户的当前数据,否则,如果授权用户的信息不在会话中,则可以为null。
Request类系统是有关传入请求的当前信息,其中包含古柯,标头和用户通道,随后可以将其发送给Push Message的...,后者在这方面已经有些幻想了。
实用程序类ModelAndView是一个页面模型,其资源名称不带扩展名,并且具有嵌入资源的属性。


似乎全部,但不是-您必须为用户配置可用的请求映射。


 package org.examples.webapp.config; import org.ioc.annotations.configuration.Property; import org.ioc.annotations.configuration.PropertyFunction; import org.ioc.web.security.configuration.HttpContainer; import org.ioc.web.security.configuration.SecurityConfigureProcessor; import org.ioc.web.security.encoder.Encoder; import org.ioc.web.security.encoder.bcrypt.BCryptEncoder; import org.ioc.web.security.filter.CorsFilter; import org.ioc.web.security.filter.CsrfFilter; @Property public class SecurityConfig implements SecurityConfigureProcessor { @Override public void configure(HttpContainer httpContainer) { httpContainer. configureRequests(). anonymousRequests("/", "/signup", "/signin"). resourceRequests("/static/**"). authorizeRequests("/loginPage", "ROLE_USER"). authorizeRequests("/signout", "ROLE_USER"). and(). configureSession(). expiredPath("/"); } @PropertyFunction public CsrfFilter csrfFilter() { return new CsrfFilter(); } @PropertyFunction public CorsFilter corsFilter() { return new CorsFilter(); } @PropertyFunction public Encoder encoder() { return new BCryptEncoder(); } } 

**说明:
@Property注释 -告诉上下文这是一个配置文件,需要初始化。
注释@PropertyFunction-通知配置分析器此函数返回某种类型,应将其初始化为组件(容器)。
接口SecurityConfigureProcessor-用于配置请求映射的实用程序。
HttpContainer模型类是一个实用程序,用于存储用户指定的请求的映射。
CsrfFilter类无效请求的过滤器(CSRF机制的实现)。
CorsFilter类是跨域资源共享过滤器。


AnonymousRequests函数 -接受无限数量的请求,不需要授权用户和角色验证(ROLE_ANONYMOUS)。
resourceRequests函数 -接收不限数量的请求,专门用于确定不需要复杂处理(css,js,图像等)的资源文件的路径。
函数authorizeRequests-接受无限数量的请求,需要授权用户以及用户固有的特定角色。
ExpiredPath函数 -清除时间已过期的会话时,此映射将重定向用户(重定向链接)。


好吧,这里有页面,脚本和网站样式(我不会深入探讨)。


扰流板方向

index.vm-主页


 <html> <head> <meta charset="utf-8"/> <title>IoC Test</title> <link rel="stylesheet" href="/static/css/bootstrap.min.css"> <link rel="stylesheet" href="/static/css/style.css"/> <link rel="stylesheet" href="/static/css/pnotify.custom.min.css"/> <link rel="stylesheet" href="/static/css/pnotify.css"/> <link rel="stylesheet" href="/static/css/pnotify.buttons.css"/> </head> <body> <div class="container"> <h1>IoC Starter Test</h1> <br> <h4>Create user</h4> <br> <form id="creation"> <label for="username">Username: </label> <input type="text" id="username" name="username" class="color-input-field"/> <label for="password">Password: </label> <input type="password" id="password" name="password" class="color-input-field"/> <label for="repeatedPassword">Repeate: </label> <input type="password" id="repeatedPassword" name="repeatedPassword" class="color-input-field"/> <button type="button" class="btn btn-success btn-create">Sing up!</button> </form> <h4>Authenticate</h4> <br> <form id="auth"> <label for="username">Username: </label> <input type="text" id="username" name="username" class="color-input-field"/> <label for="password">Password: </label> <input type="password" id="password" name="password" class="color-input-field"/> <button type="button" class="btn btn-danger btn-auth">Sing in!</button> </form> </div> <script type="text/javascript" src="/static/js/jquery.js"></script> <script type="text/javascript" src="/static/js/bootstrap.min.js"></script> <script type="text/javascript" src="/static/js/scripts.js"></script> <script type="text/javascript" src="/static/js/pnotify.js"></script> <script type="text/javascript" src="/static/js/pnotify.buttons.js"></script> </body> </html> 

auth.vm-显示授权用户


 <html> <head> <meta charset="utf-8"/> <title>IoC Test</title> <link rel="stylesheet" href="/static/css/bootstrap.min.css"> <link rel="stylesheet" href="/static/css/style.css"/> <link rel="stylesheet" href="/static/css/pnotify.custom.min.css"/> <link rel="stylesheet" href="/static/css/pnotify.css"/> <link rel="stylesheet" href="/static/css/pnotify.buttons.css"/> </head> <body> <div class="container"> <h1>Authorized page</h1> <br> <h4>Test auth data</h4> <div id="auth_data"> #if($!account) <h4>Hello @$!account.username, You successfully authenticated!</h4> <br> <button type="button" class="btn btn-success btn-logout">Logout!</button> #end </div> </div> <script type="text/javascript" src="/static/js/jquery.js"></script> <script type="text/javascript" src="/static/js/bootstrap.min.js"></script> <script type="text/javascript" src="/static/js/scripts.js"></script> <script type="text/javascript" src="/static/js/pnotify.js"></script> <script type="text/javascript" src="/static/js/pnotify.buttons.js"></script> </body> </html> 

scripts.js-控制器,用于向服务器发送和接收请求信息


 $(function () { $(".btn-create").click(function () { var cooki = cookie(); document.cookie = 'CSRF-TOKEN=' + cooki; $.ajax({ url: "/signup", data: $('#creation').serialize(), headers: {'X-CSRF-TOKEN': cooki}, crossDomain: true, xhrFields: { withCredentials: true }, type: "POST" }).done(function (data) { switch (data.type) { case 'OK': new PNotify({ title: 'Success', text: data.message, type: 'success', hide: false }); break; case 'ERROR': new PNotify({ title: 'Error', text: data.message, type: 'error', hide: false }); break; } }); }); $(".btn-auth").click(function () { var cooki = cookie(); document.cookie = 'CSRF-TOKEN=' + cooki; $.ajax({ url: "/signin", data: $('#auth').serialize(), headers: {'X-CSRF-TOKEN': cooki}, crossDomain: true, xhrFields: { withCredentials: true }, type: "POST" }).done(function (data) { switch (data.type) { case 'OK': new PNotify({ title: 'Success', text: data.message, type: 'success', hide: false }); setTimeout(function () { window.location = "/loginPage"; }, 5000); break; case 'ERROR': new PNotify({ title: 'Error', text: data.message, type: 'error', hide: false }); break; } }); }); $(".btn-logout").click(function () { $.ajax({ url: "/signout", crossDomain: true, xhrFields: { withCredentials: true }, type: "GET" }).done(function (data) { switch (data.type) { case 'OK': new PNotify({ title: 'Success', text: 'Logouting...', type: 'success', hide: false }); setTimeout(function () { window.location = data.message; }, 5000); break; case 'ERROR': new PNotify({ title: 'Error', text: data.message, type: 'error', hide: false }); break; } }); }); }); function cookie(a) { return a // if the placeholder was passed, return ? ( // a random number from 0 to 15 a ^ // unless b is 8, Math.random() // in which case * 16 // a random number from >> a / 4 // 8 to 11 ).toString(16) // in hexadecimal : ( // or otherwise a concatenated string: [1e7] + // 10000000 + -1e3 + // -1000 + -4e3 + // -4000 + -8e3 + // -80000000 + -1e11 // -100000000000, ).replace( // replacing /[018]/g, // zeroes, ones, and eights with cookie // random hex digits ) } 

编译,运行我们拥有的所有内容。
如果一切正确,下载结束时我们将看到类似以下内容:


记录

[21.10.18 22:29:51:990]信息web.model.mapping.MappingContainer:将方法[/],方法= [GET]映射到[public org.ioc.web.model.ModelAndView org.examples.webapp .mapping.MainMapping.index()]
[21.10.18 22:29:51:993]信息web.model.mapping.MappingContainer:将方法[/注册],方法= [POST]映射到[public org.examples.webapp.responces.IMessage org.examples。 webapp.mapping.MainMapping.createUser(java.lang.String,java.lang.String,java.lang.String)]
[21.10.18 22:29:51:993]信息web.model.mapping.MappingContainer:将方法[/登录],方法= [POST]映射到[public org.examples.webapp.responces.IMessage org.examples。 webapp.mapping.MainMapping.auth(org.ioc.web.model.http.Request,java.lang.String,java.lang.String)]
[21.10.18 22:29:51:993]信息web.model.mapping.MappingContainer:将方法[/登出],方法= [GET]映射到[public org.examples.webapp.responces.IMessage org.examples。 webapp.mapping.MainMapping.signout(org.ioc.web.model.http.Request)]
[21.10.18 22:29:51:995]信息web.model.mapping.MappingContainer:将方法[/ loginPage],方法= [GET]映射到[public org.ioc.web.model.ModelAndView org.examples。 webapp.mapping.MainMapping.authenticated(org.examples.webapp.domain.entity.TblAccount)]
[21.10.18 22:29:51:997]信息ioc.web.factory.HttpInitializerFactory:在端口上启动Http服务器:8081(http)


结果:
1)主页
索引
2)注册
签约
3)认证
认证
4)带有授权结果的页面(正确输入登录名和密码后重定向)
结果
5)从会话中清除授权信息,并将用户重定向到起始页
图片
6)未经授权的用户试图进入带有会话认证信息的页面
图片


结束


该项目正在开发中,欢迎“贡献者”和原创想法,因为很难单独完成此项目。
项目资料库
语境
ORM工厂
网络工厂
例子
本文中的当前示例
此外,在存储库中,还包含使用“示例”模块中所有功能的示例 ,正如他们所说的“祝您好运,玩得开心”,谢谢大家的关注。

Source: https://habr.com/ru/post/zh-CN427247/


All Articles