微信号:infoqchina

介绍:有内容的技术社区媒体

代码大爆炸|用Spring Boot创建微服务的21种代码描述(上)

2015-05-25 12:44 邵思华 译

Spring Boot


Spring Boot这个框架在经历了不断的演变之后,如今已经能够用于开发Java微服务了。Boot是基于Spring框架进行开发的,也继承了Spring的成熟性。它通过一些内置的固件封装了底层框架的复杂性,以帮助使用者进行微服务的开发。Spring Boot的一大优点是提高开发者的生产力,因为它已经提供了许多通用的功能,例如RESTful HTTP以及嵌入式的web应用程序运行时,因此很容易进行装配及使用。在许多方面上,它也是一种“微框架”,允许开发者选择在整个框架中他们所需的那部分,而无需使用庞大的、或是不必要的运行时依赖。这也让Boot应用程序能够被打包为多个小单元以进行部署,并且该框架还能够使用构建系统生成可部署文件,例如可运行的Java档案包。

Spring Boot团队提供了一种便利的机制,让开发者能够简单地上手创建应用程序,也就是所谓的Spring Initializr。这个页面的作用是引导基于Boot的web应用程序的构件配置,并且允许开发者在多个分类中选择在项目中需要使用的类库。开发者只需要输入项目的一些元数据、选择所需的依赖项、并且单击“生成项目”按钮,就能够生成一个基于Maven或Gradle的Spring Boot项目的压缩文件了。文件里提供了用于开始设计项目的脚手架代码,对于首次使用这个框架的开发者来说是个绝佳的起点。

作为一个框架,Boot中内建了一些聚合模块,通常称为“启动者”。这些启动模块中是一些类库的已知的、良好的、具备互操作性的版本的组合,这些类库能够为应用程序提供某些方面的功能。Boot能够通过应用程序的配置对这些类库的进行设置,这也为整个开发周期中带来了配置胜于约定的便利性。这些启动模块中有许多是专门用于进行微服务架构开发的,它们为应用程序的开发者带来了一些免费的关键功能。在Spring Boot中实现一个基于HTTP的RESTful微服务,只需简单地加入actuator与web启动模块就足够了。web模块将提供嵌入式的运行时,而且能够让使用者基于RESTful HTTP控制器进行微服务API的开发,而actuator模块则为对外暴露的试题、配置参数和内部组件的映射提供了基本功能与RESTful HTTP终结点,因而使微服务能够正常运转,同时也为调试提供了极大的便利。

作为一个微服务框架,Boot的很大一部分价值在于它能够无缝地为基于Maven和Gradle的项目提供各种构建工具。通过使用Spring Boot插件,就能够利用该框架的能力,将项目打包为一个轻量级的、可运行的部署包,而除此之外几乎不需要进行任何额外的配置。在列表1中的代码展示了一个Gradle的构建脚本,可作为运行某个Spring Boot微服务的起点。此外,也可在Spring Initializr网站上选择使用较繁琐的Maven POM的示例,同时需要将应用程序的启动类的地址告诉该插件。而在使用Gradle时则无需进行这方面的配置,因为插件本身就能够找到这个类的地址。

buildscript { repositories { jcenter() } dependencies { classpath 'org.springframework.
boot:spring-boot-gradle-plugin:1.2.0.RELEASE'
}}apply plugin: 'spring-boot'
repositories
{ jcenter()}dependencies { compile "org.springframework.
boot:spring-boot-starter-actuator
" compile "org.springframework.
boot:spring-boot-starter-web
"}


列表 1 – Gradle的构建脚本

如果选择使用Spring Initializr上的项目,就需要让项目结构符合常规的需求,只需遵循Maven风格的项目结构就能够实现这一点。代码必须被保存在src/main/java文件夹下,这样才能够正确地编译。该项目随后还要提供一个应用程序的入口点。在Spring Initializr的脚手架代码中有一个名为DemoApplication.java的文件,它的作用正是该项目的main类。可以随意对这个类进行重命名,通常来说将其命名为“Main”就可以了。列表1.1的示例描述了开始开发一个微服务所需的最少代码。

import org.
springframework.boot.
SpringApplication;import org.
springframework.boot.autoconfigure.
EnableAutoConfiguration;
@EnableAutoConfigurationpublic class
Main { public static void main
(String[] args) { SpringApplication.run
(Main.class); }}


列表 1.1 - Spring Boot应用

通过在Main类中使用“EnableAutoConfiguration”标注,该框架就能够进行行为的配置,以引导应用程序的启动与运行。这些行为很大程度上是通过约定用于配置的方式确定的,为此Boot将对classpath进行扫描,以确定微服务中需要具备哪些功能。在上面的示例中,该微服务选择了actuator与web这两个启动模块,因此该框架能够确定这个项目是一个微服务,引导某个嵌入的Tomcat容器的启动,并通过某个预先配置的终结点提供该服务。在该示例中的代码并没有进行太多工作,但只需简单地启动该示例,就能够使actuator模块所暴露的终结点开始运行。只需将该项目导入任何IDE,随后为“Main”类创建一个“作为Java应用程序运行”的配置,就能够启动这个微服务了。此外,也可以选择在命令行中运行gradle bootRun这个Gradle任务,或是针对Maven的mvn spring-boot:run命令,也能够启动该应用程序,具体的命令取决于你选择了哪种项目配置。

操作数据


接下来我们要实现之前所说的那个“产品的垂直分片”,考虑一下“产品详细信息”这个服务,它与“产品价格”这个服务一起提供了登录页面体验的详细信息。至于微服务的职责,它的数据领域应当是与某个“产品”相关的属性的子集,包括产品名称、简短描述、详细描述、以及一个库存id。可以使用Java bean对这些信息进行建模,正如列表1.2中的代码所描述的一样。

import javax.persistence.
Entity;import javax.persistence.Id;
@Entitypublic class ProductDetail { @Id private String productId; private String productName; private String shortDescription; private String longDescription; private String inventoryId; public String getProductId() { return productId; } public void setProductId
(String productId) { this.productId = productId; } public String getProductName() { return productName; } public void setProductName
(String productName) { this.productName = productName; } public String getShortDescription() { return shortDescription; } public void setShortDescription
(String shortDescription) { this.shortDescription = shortDescription; } public String getLongDescription() { return longDescription; } public void setLongDescription
(String longDescription) { this.longDescription = longDescription; } public String getInventoryId() { return inventoryId; } public void setInventoryId
(String inventoryId) { this.inventoryId = inventoryId; }}


列表1.2 —— 产品详细信息的POJO对象

在ProductDetail这个Java bean中有一点要特别注意,这个类使用了JPA标注,以表示它是一个实体。Spring Boot中专门提供了一个可用于JPA实体与关系型数据库数据源的启动模块。考虑一下列表1中的构建脚本,我们可以在其中的“依赖”一节中加入这个Boot的启动模块,以用于持久化数据集,如列表1.3中的代码所示。

dependencies { compile "org.springframework.
boot:spring-boot-starter-actuator
" compile "org.springframework.
boot:spring-boot-starter-web
" compile "org.springframework.
boot:spring-boot-starter-data-jpa
" compile
'com.h2database:h2:1.4.184'}



列表 1.3 —— 在构建脚本中设置Spring Boot的依赖

出于演示与原型的目的,该项目中现在还包括了内嵌的h2数据库类型。Boot的自动配置机制能够检测到classpath中存在h2,随后为ProductDetail实体生成必要的表结构。在内部,Boot会调用Spring Data进行对象实体映射操作,有了它之后,我们就可以利用它的约定和机制与数据库打交道了。Spring Data中提供了一个便捷的抽象,也就是“repository”的概念,它本质上就是一种数据访问对象(DAO),该对象在启动时会由框架为我们自动装配。为了实现ProductDetail实体的CRUD功能,我们只需要创建一个接口,扩展在Spring Data中内置的CrudRepository即可,正如列表1.4中的代码所示。

import org.
springframework.data.repository.
CrudRepository;
import org.springframework.
stereotype.Repository;
@Repositorypublic interface
ProductDetailRepository extends
CrudRepository
<ProductDetail, String>{}


列表 1.4 —— 产品信息的数据访问对象(Spring Data Repository

在接口定义中的@Repository标注将通知Spring,这个类的作用是一个DAO。这个标注也是一种特别的机制,我们可以通过这种机制通知框架,让框架自动将其进行装配,并分配到微服务的配置中,从而让我们可以使用依赖注入的方式访问它。为了在Spring中应用这一特性,我们还必须在列表1.1中定义的Main类上添加@ComponentScan这个额外的标注。当微服务启动之后,Spring将会对项目的classpath进行扫描以寻找各种组件,并且将这些组件作为应用程序中需要自动装配的备选组件。

为了展现微服务的新能力,请仔细阅读列表1.5中的代码,这里我们利用了一个先决条件,就是Boot会在main()方法中为我们提供一个指向Spring的ApplicationContext的引用。

import org.springframework.boot.
SpringApplication;import org.
springframework.boot.
autoconfigure.
EnableAutoConfiguration;import org.
springframework.context.
ApplicationContext;import org.
springframework.context.
annotation.ComponentScan;
@ComponentScan
@EnableAutoConfigurationpublic
class Main { public static void main
(String[] args) { ApplicationContext ctx =
SpringApplication.run(Main.class); ProductDetail detail =
new ProductDetail(); detail.setProductId
("ABCD1234"); detail.setProductName
("Dan's Book of Writing"); detail.setShortDescription
("A book about writing books."); detail.setLongDescription
("In this book about writing books,
Dan will show you how to write a book.
"); detail.setInventoryId("009178461"); ProductDetailRepository repository =
ctx.getBean(ProductDetailRepository.class); repository.save(detail); for (ProductDetail productDetail
: repository.findAll()) { System.out.
println(productDetail.getProductId()); } }}

列表 1.5 —— 展现加载数据的功能

在这个简单的示例中,我们为一个ProductDetail对象加载了某些数据,我们通过调用ProductDetailRepository的方法将产品信息进行保存,随后再次调用这个repository对象,从数据库中取回产品的信息。到目前为止,对于在微服务使用持久化数据,没有进行任何额外的配置。我们可以使用列表1.5中的这个原型代码作为定义RESTful HTTP API契约的基础,通过Spring中提供的@RestController标注就可以实现。

设计API


对于“产品信息”这个微服务来说,提供简单的CRUD式功能或许就已经足够了,但也许它还需要提供一些扩展功能,例如分页的结果集和数据过滤。可以通过一个简单的控制器(controller)实现这个操作数据集的API,Spring会将该控制器映射到某个HTTP的路由上。下方的列表1.6中的代码示例可以作为一个起点,这个API暴露了create与findAll方法,通过它可以实现之前那个原型中所演示的代码功能。


import org.
springframework.beans.factory.
annotation.Autowired;
import org.springframework.web.
bind.annotation.*;@RestController
@RequestMapping("/products")
public class
ProductDetailController { private final
ProductDetailRepository repository; @Autowired
public ProductDetailController
(ProductDetailRepository repository) { this.repository = repository; } @RequestMapping
(method = RequestMethod.GET) public Iterable findAll() { return repository.findAll(); } @RequestMapping
(method = RequestMethod.POST) public ProductDetail create
(@RequestBody ProductDetail detail) { return repository.save
(detail); }}

列表 1.6 —— Product Detail控制器类

Spring中提供的@RestController标注将通知该框架,让框架为我们实现数据序列化与数据绑定的大部分繁重工作。此外,对于那些将为这个微服务生成数据的服务来说,我们只需为create()方法的参数标注为@RequestBody,Spring就能够自动为我们生成该对象的内容。随后就可以使用系统自动装配的ProductDetailRepository对象保存相应的ProductDetail对象。Boot为Spring中内置提供的这些功能加入了一些额外的数据转换器,它们将通过Jackson类库,将ProductDetail对象序列化为JSON格式,以便微服务的API的调用者进行操作。在列表1.6中的控制器示例的基础上,如果该服务的/products终结点收到了一个JSON格式的请求,那么该服务就会创建一个新的产品信息项,正如列表1.7中所描述的那样。

{ "productId": "DEF0000", "productName": "MakerBot", "shortDescription": 
"A product that makes other products", "longDescription":
"This is an extended description
for a makerbot, which is basically a product
that makes other products.
", "inventoryId": "00854321"}

列表 1.7 —— 用于表现某个产品的JSON结构

通过对/products这个地址进行一个HTTP GET请求,可以刷新产品的详细信息,并显示新创建的产品细节内容。

在微服务的create()中基本上只有一个用例,就是进行数据绑定并保存到repository中。但在某些情况下,该服务还需要执行一些较复杂的业务逻辑,以确保保存到产品信息中的数据的准确性。我们可以通过使用Spring中内置的校验框架,在进行数据绑定时确认产品信息中的数据符合微服务的业务逻辑。在列表1.8中的代码展现了对ProductDetail校验逻辑的一种实现,它将调用另一个微服务中的方法,以确定所提供的库存ID的有效性。


import org.springframework.
beans.factory.annotation.
Autowired;import org.
springframework.stereotype.
Component;import org.
springframework.validation.*;
@Componentpublic class
ProductDetailValidator
implements Validator { private final
InventoryService inventoryService; @Autowired public
ProductDetailValidator
(InventoryService
inventoryService) { this.
inventoryService = inventoryService; } @Override
public boolean supports
(Class<?>clazz) { return ProductDetail.
class.isAssignableFrom(clazz); } @Override public void
validate(Object target,
Errors errors) { ProductDetail detail
= (ProductDetail)target; if (!inventoryService.
isValidInventory(detail.
getInventoryId())) { errors.
rejectValue("inventoryId",
"inventory.id.invalid",
"Inventory ID is invalid"); } }}

列表1.8 ——ProductDetail的校验逻辑

这段示例代码中的InventoryService中的逻辑有些生硬,但不难看出这种进行数据校验的机制具有固有的灵活性,这也利益于该服务能够对其它微服务进行查询调用,以获得其它微服务对于整个数据领域中某些子数据的信息。

为了在数据绑定时能够使用ProductDetailValidator的功能,需要在Spring的数据绑定器中进行注册,而注册时机是特定于控制器的。在下方的列表1.9中对控制器的代码进行了改动,展现了如何在控制器中对校验器进行自动装配,并通过initBinder()方法将其注册进行数据绑定的过程。@InitBinder这个标注将通过Spring,我们将对这个类的默认数据绑定器进行自定义。此外,请注意thecreate()方法中的ProductDetail对象参数现在加上了一个@Valid标注,该标注的作用是通知数据绑定器,我们需要在数据绑定时对请求体进行校验。而Spring中内置的校验器也将提供JSR-303 与JSR-349这两种数据校验规范(Bean校验)的字段级标注。

import org.springframework.
beans.factory.annotation.
Autowired;import org.
springframework.web.bind.
WebDataBinder;import org.
springframework.web.bind.
annotation.*;import
javax.validation.Valid;
@RestController
@RequestMapping("/products")
public class
ProductDetailController { private final
ProductDetailRepository repository; private final
ProductDetailValidator validator; @Autowired
public ProductDetailController
(ProductDetailRepository
repository,
ProductDetailValidator validator) { this.repository = repository; this.validator = validator; } @InitBinder
protected void initBinder
(WebDataBinder binder) { binder.
addValidators(validator); } @RequestMapping
(method = RequestMethod.GET) public Iterable findAll() { return repository.
findAll(); } @RequestMapping
(method = RequestMethod.POST) public ProductDetail
create(@RequestBody @Valid
ProductDetail detail) { return repository.
save(detail); }}

列表1.9 —— 经过修改后的Product Detail控制器,现在加入了校验器

如果该API的调用者在POST提交的JSON结构中没有包含一个有效的库存ID,Spring将会产生一个校验失败的错误,并且为调用者返回一个“400 – Bad Request”的HTTP状态码。由于控制器的定义使用了RestController这个标注,因此Spring能够将校验失败的信息进行正确地格式化,让调用者能够理解其内容。作为这个微服务的开发者,实现这一功能无需进行任何额外的配置。

对于电子商务网站这个示例来说,一个仅包含简单的CRUD REST API的产品详细信息微服务没有什么太大的作用。这个服务还需要提供对产品信息结果列表进行分页以及排序的功能,并且提供某种程序上的搜索功能。为了实现第一个需求,需要对ProductDetailController中的findAll()这个控制器action方法进行修改,让它能够接受由API使用者所定义的数据范围所对应的查询参数,然后该方法就可以使用Spring Data中内置的PagingAndSortingRepositorytype类,在findAll()方法中对repository进行调用时提供分页及排序的参数。我们需要修改ProductDetailRepository,让它继承自这个新的类型,如列表1.10中的代码所示。

import org.
springframework.data.
repository.
PagingAndSortingRepository;
import org.springframework.
stereotype.Repository;
import java.util.List;
@Repositorypublic interface
ProductDetailRepository extends
PagingAndSortingRepository
<ProductDetail, String> {}

列表1.10 —— 修改后的ProductDetailRepository提供了对分页与排序的支持

列表1.11中的代码展现了经过修改后的findAll这个控制器方法,它能够利用repository中新的分页与排序功能。如果某个对/products这个终结点的API调用提供了?page=0&count=20这个查询字符串,该方法就能够返回数据库中的前20条结果。在这个示例中的代码还利用了Spring的功能,为查询参数赋予了默认值,因此这些参数中的大部分都成为可选参数了。

@RequestMapping
(method = RequestMethod.GET)
public Iterable findAll
(@RequestParam(value = "page",
defaultValue = "0",
required = false) int page,
@
RequestParam(value = "count",
defaultValue = "10", required
= false) int count,@RequestParam
(value = "order",
defaultValue = "ASC",
required = false) Sort.
Direction direction,
@
RequestParam(value = "sort",
defaultValue = "productName",
required = false)
String sortProperty) { Page result = repository.
findAll(new PageRequest
(page, count, new Sort
(direction, sortProperty))); return result.getContent();}

列表1.11 ——ProductDetailController中修改后的findAll方法,现在能够支持分页及排序功能

当该电子商务网站的用户进行登陆页面时,该网页会通过贪婪查询方式加载10条或20条结果,随后当滚动条到达页面上的某个位置,或是经过一段时间后,通过延迟加载的方式获取之后的50条结果。通过这个内置的分页功能,调用者就能够控制每次调用需要返回的数据量。列表1.12中描述了ProductDetailController的完整实现。


import com.fasterxml.
jackson.databind.ObjectMapper;
import org.springframework.
beans.MutablePropertyValues;
import org.springframework.
beans.factory.annotation.
Autowired;import org.
springframework.data.domain.*;
import org.springframework.
http.*;import org.
springframework.validation.
DataBinder;import org.
springframework.web.bind.
WebDataBinder;import org.
springframework.web.bind.
annotation.*;import javax.
servlet.http.
HttpServletRequest;
import javax.validation.
Valid;import java.io.
IOException;@RestController
@RequestMapping("/products")
public class
ProductDetailController { private final
ProductDetailRepository repository; private final
ProductDetailValidator validator; private final
ObjectMapper objectMapper; @Autowired
public ProductDetailController
(ProductDetailRepository
repository,
ProductDetailValidator validator, ObjectMapper objectMapper) { this.repository = repository; this.validator = validator; this.objectMapper = objectMapper; } @InitBinder
protected void initBinder
(WebDataBinder binder) { binder.
addValidators(validator); } @RequestMapping
(method = RequestMethod.GET) public Iterable findAll
(@RequestParam(value = "page",
defaultValue = "0",
required = false) int page,@RequestParam(value = "count",
defaultValue = "10",
required = false) int count, @RequestParam(value = "order",
defaultValue = "ASC",
required = false) Sort.
Direction direction,@RequestParam(value = "sort",
defaultValue = "productName",
required = false)
String sortProperty) {Page result = repository.
findAll(new PageRequest
(page, count, new Sort
(direction, sortProperty)));return result.getContent(); } @RequestMapping
(value = "/{id}",
method = RequestMethod.GET) public ProductDetail
find(@PathVariable String id) { ProductDetail detail
= repository.findOne(id); if (detail == null) { throw new
ProductNotFoundException(); } else { return detail; } } @RequestMapping
(method = RequestMethod.POST) public ProductDetail create
(@RequestBody
@Valid ProductDetail detail) { return repository.
save(detail); } @RequestMapping
(value = "/{id}",
method = RequestMethod.PUT) public HttpEntity update
(@PathVariable String id,
HttpServletRequest request)
throws IOException { ProductDetail
existing = find(id); ProductDetail updated
= objectMapper.
readerForUpdating(existing).
readValue(request.getReader()); MutablePropertyValues
propertyValues = new
MutablePropertyValues(); propertyValues.add
("productId", updated.
getProductId()); propertyValues.add
("productName", updated.
getProductName()); propertyValues.add
("shortDescription", updated.
getShortDescription()); propertyValues.add
("longDescription", updated.
getLongDescription()); propertyValues.add
("inventoryId", updated.
getInventoryId()); DataBinder binder =
new DataBinder(updated); binder.
addValidators(validator); binder.
bind(propertyValues); binder.validate(); if (binder.
getBindingResult().
hasErrors()) { return new
ResponseEntity<>(binder.
getBindingResult().
getAllErrors(),
HttpStatus.BAD_REQUEST); } else { return new
ResponseEntity<>(updated,
HttpStatus.ACCEPTED); } } @RequestMapping
(value = "/{id}",
method = RequestMethod.DELETE) public HttpEntity
delete(@PathVariable
String id) { ProductDetail
detail = find(id); repository.
delete(detail); return new
ResponseEntity<>
(HttpStatus.ACCEPTED); } @ResponseStatus
(HttpStatus.NOT_FOUND) static class
ProductNotFoundException
extends RuntimeException { }}


列表1.12 —— Product DetailController的完整实现

毫无疑问,除了数据的分页与排序之外,这个电子商务网站还需要提供一些类似于搜索引擎一样的功能。由于在垂直分片中的每个微服务都对自己的数据领域子集进行维护,因此它也理应负责自身的搜索功能。这也让调用者能够异步地对整个数据领域中很大一部分的属性进行搜索。

Spring Data允许对repository的接口附加某个方法签名,在其中加入自定义的查询。这表示repository能够使用一种预先确定的JPA查询,它将对每个产品信息对象中的一个属性子集进行查询,这就让微服务能够具备一些原始的搜索功能。在列表1.13中所描述的代码中对Product Detail Repository进行了修改,其中加入了一个search()方法,它能够接受查询语句,并尝试对product Name或long Description字段进行大小写无关的匹配,并将一个结果列表返回给调用 者。

import org.
springframework.data.jpa.
repository.Query;import org.
springframework.data.
repository.
PagingAndSortingRepository;
import org.springframework.
stereotype.Repository;import
java.util.List;
@Repositorypublic interface
ProductDetailRepository
extends
PagingAndSortingRepository
<ProductDetail, String> { @Query("select p
from ProductDetail p
where UPPER(p.productName)
like UPPER(?1) or
" + "UPPER(p.
longDescription) like UPPER(?1)
") List search(String term);}

列表1.13 ——ProductDetailRepository中的自定义查询

为了公开这个搜索功能,我们将创建另一个RestController,并将它映射到/search这个终结点,如列表1.1.4中所示。

import org.
springframework.beans.
factory.annotation.Autowired;
import org.springframework.
web.bind.annotation.*;import
java.util.ArrayList;
import java.util.List;
@RestController@RequestMapping
("/search")public class
ProductDetailSearchController { private final
ProductDetailRepository
repository; @Autowired
public
ProductDetailSearchController
(ProductDetailRepository
repository) { this.repository = repository; } @RequestMapping
(method = RequestMethod.GET) public List search
(@RequestParam("q")
String queryTerm) { List productDetails
= repository.search
("%"+queryTerm+"%"); return productDetails
== null ? new ArrayList
<>() : productDetails; }}


列表1.14 —— 用于对ProductDetail进行搜索的控制器 Search controller for ProductDetails

将来还可以进一步增加这个ProductDetailSearchController的功能,可以让它在查询时实现与ProductDetailController相同的分布及排序功能。



下篇将从配置、打包、网关API方面继续对用Spring Boot创建微服务这一话题进行探讨。敬请关注。


另外,关于您对本公众号内容的意见及建议,欢迎以微信及各种方式提出,我们会针对内容进行及时回复,及更多更深入的沟通交流。



回复关键词查看对应内容:

React | 架构师 | 运维 | 云 | 开源 | Kubernetes | 架构 | 人工智能 | Kafka | Docker | Netty | CoreOS | QCon | Github | Swift | 敏捷 | 语言 | 程序员 | 实践 | 物联网 |



如果想要评论本篇文章,想看下其他读者都有什么话想说,欢迎点击“阅读原文”参与讨论。


版权及转载声明:


极客邦科技专注为技术人提供优质内容传播。尊重作者、译者、及InfoQ网站编辑的劳动,所有内容仅供学习交流传播,不支持盗用。未经许可,禁止转载。若转载,需予以告知,并注明出处。


 
InfoQ 更多文章 Facebook如何实现PB级别数据库自动化备份 学术派Google软件工程师Matt Welsh谈移动开发趋势 Spotify为什么要使用一些“无聊”的技术? 妹纸们放假了,汉纸们做啥? 大多数重构可以避免
猜您喜欢 用Docker重新定义Java虚拟化部署实战案例 12个鲜为人知的CSS技能(上) 全程揭秘!从零开始做一个 App需要多少钱? 揭秘地下网络黑产链:普通黑客月入80000美元? Python基础教程17:日期和时间