每一件美好的事情,开始都是困难的。——斯宾塞
Cartisan 是我五六年前取的名字,那时老罗刚开始搞手机,叫 Smartisan,我一直比较认同他提的一些价值观,所以就依葫芦画瓢,取了个 Cartisan,Code artisan,意为代码工匠。
我在【开篇】里说过,我要做一个系统,把自己学习的知识串起来,积累成一个产品,并且把这个过程通过博客的方式记录下来。
以前根据自己的工作情况,想写过不少类型的系统。我现在选择做一个电商系统,一是因为电商业务大家都比较了解,技术覆盖面够广,二是在开源社区有比较成熟的系统可参考。
现阶段业务逻辑主要参考 mall 。
根据我个人学习掌握的知识,我将在 Cartisan 上做以下实践,且不限于列表中的技术。
Cartisan 将使用前后端分离的架构,这个架构图我会在后面的开发过程中不断演化丰富。
现在的结构非常简单,管理后台向 Zuul 做的网关发送对商品服务的请求,Zuul 本身注册在 Eureka 做的注册中心上,并且从注册中心获取注册的服务列表,然后把请求转发给商品服务。
程序员很多时候被称作 CRUD boy 不是没理由的,毕竟大多业务就是简单的 CRUD。
这里以商品品牌维护为例,通过简单的 CRUD,来走通这个简单的架构思路。目的有二:
common-core、common-mysql、common-web 三个是 Cartisan 的基础库,一些通用的类,框架层面的基类、持久化处理、日志等等都在这里,每一个以后都单独整理说明。
Zuul 做的网关和商品服务都会向 Eureka 注册,这样就可以通过网关地址在服务名称完成对服务的访问。
pom 文件比较简单,根据业务场景,引入对应的基础库,基础库已经包含了所有要使用的其它依赖。
<dependencies> <!-- cartisan 基础库 --> <dependency> <groupId>com.cartisan</groupId> <artifactId>cartisan-common-web</artifactId> <version>1.0-SNAPSHOT</version> <scope>compile</scope> </dependency> <dependency> <groupId>com.cartisan</groupId> <artifactId>cartisan-common-mysql</artifactId> <version>1.0-SNAPSHOT</version> <scope>compile</scope> </dependency> </dependencies>
每个服务都是基于 Spring boot、Spring cloud 构建,CartisanApplication 是基于这两者构建基类,减少应用的重复配置及扫描基础库。
public class GoodsApplication extends CartisanApplication { public static void main(String[] args) { SpringApplication.run(GoodsApplication.class); } @Bean public IdWorker idWorker() { return new IdWorker(1, 1); } @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } }
@SpringCloudApplication @ComponentScan(value = "com.cartisan.common") public class CartisanApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { return application.sources(this.getClass()); } @Bean public ServletRegistrationBean hystrixStreamServlet() { final HystrixMetricsStreamServlet hystrixMetricsStreamServlet = new HystrixMetricsStreamServlet(); final ServletRegistrationBean<HystrixMetricsStreamServlet> registrationBean = new ServletRegistrationBean<>(hystrixMetricsStreamServlet); // 系统启动时的加载顺序 registrationBean.setLoadOnStartup(1); registrationBean.addUrlMappings("/hystrix.stream"); registrationBean.setName("HystrixMetricsStreamServlet"); return registrationBean; } }
每个服务的配置,都根据生产、测试、开发等环境进行拆分。bootstrap 的配置是最优先加载的,所以使用 bootstrap 可以很好解决当使用配置中心时,对配置中心的配置及优先于配置中心的配置。
微服务或者说一个限界上下文都应该独立的数据库,所以把每个服务的建库建表及初始化语句单独存放。
服务最重要的一点是测试,一般可以使用 PostMan,但访问请求的管理是个问题。IDEA 的 HttpClient 插件很好的解决了这个问题,把请求保存到一个 .http 文件中,在 IDEA 中就可以直接完成请求测试。
### 获取所有品牌列表 GET http://localhost:9002/brands Accept: application/json Content-Type: application/json;charset=UTF-8 ### 获取品牌 GET http://localhost:9002/brands/1 Accept: application/json Content-Type: application/json;charset=UTF-8 ### 搜索品牌(不带参数) GET http://localhost:9002/brands/search/1/10 Accept: application/json Content-Type: application/json;charset=UTF-8 ### 搜索品牌 GET http://localhost:9002/brands/search/1/10?name=car Accept: application/json Content-Type: application/json;charset=UTF-8 ### 添加品牌 POST http://localhost:9002/brands Accept: application/json Content-Type: application/json;charset=UTF-8 { "name": "cartisan", "firstLetter": "C", "isManufacturer": true, "isShow": true, "logo": "logo", "bigPic": "bigPic", "sort": 100 } ### 修改车型 PUT http://localhost:9002/brands/11 Accept: application/json Content-Type: application/json;charset=UTF-8 { "name": "cartisan", "firstLetter": "C", "isManufacturer": true, "isShow": true, "logo": "logo1", "bigPic": "bigPic1", "sort": 100 } ### 删除车型 DELETE http://localhost:9002/brands/11 Accept: application/json Content-Type: application/json;charset=UTF-8 ###
一般情况下领域模型承担了 DO 与 PO 的职责。ORM 选用 JPA 自带的 Hibernate 来完成。AbstractEntity 提供了常用的创建、更新时间的记录及软删除的支持。
@Entity @Table(name = "goods_brands") @Where(clause = "active=1 and deleted=0") @Data public class Brand extends AbstractEntity { @Id @Column(name = "id") @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "name") private String name; @Column(name = "first_letter") private String firstLetter; @Column(name = "is_manufacturer") private Boolean isManufacturer; @Column(name = "is_show") private Boolean isShow; @Column(name = "logo") private String logo; @Column(name = "big_pic") private String bigPic; @Column(name = "sort") private Long sort; }
public interface BrandRepository extends BaseRepository<Brand, Long> { }
服务一般以 Dto 作返回值,简单情况下,增改直接使用 Dto 传入,复杂业务场景,可以专门定义请求的 Parameter 对象。
@Service public class BrandService { @Autowired private BrandRepository repository; public List<BrandDto> findAllBrands() { final List<Brand> brands = repository.findAll(); return brands.stream().map(BrandDto::convertFrom).collect(Collectors.toList()); } public BrandDto getBrand(Long id) { return BrandDto.convertFrom(repository.findById(id).get()); } public PageResult<BrandDto> searchBrands(String name, Integer currentPage, Integer pageSize) { PageRequest pageRequest = PageRequest.of(currentPage - 1, pageSize, new Sort(Sort.Direction.DESC, "sort")); final Page<Brand> searchResult = repository.findAll((Specification<Brand>) (root, query, criteriaBuilder) -> { List<Predicate> predicateList = new ArrayList<>(); if (StringUtils.isNotBlank(name)) { predicateList.add(criteriaBuilder.like(root.get("name"), "%" + name + "%")); } return criteriaBuilder.and(predicateList.toArray(new Predicate[predicateList.size()])); }, pageRequest); return new PageResult<>(searchResult.getTotalElements(),searchResult.getTotalPages(), searchResult.map(BrandDto::convertFrom).getContent()); } @Transactional(rollbackOn = Exception.class) public void addBrand(BrandDto brandDto) { final Brand brand = new Brand(); BeanUtils.copyProperties(brandDto, brand); if (StringUtils.isEmpty(brand.getFirstLetter())) { brand.setFirstLetter(brand.getName().substring(0, 1)); } repository.save(brand); } @Transactional(rollbackOn = Exception.class) public void editBrand(Long id, BrandDto brandDto) { final Brand brand = repository.findById(id).get(); BeanUtils.copyProperties(brandDto, brand); brand.setId(id); if (StringUtils.isEmpty(brand.getFirstLetter())) { brand.setFirstLetter(brand.getName().substring(0, 1)); } // TODO: 更新品牌时要更新商品中的品牌名称 repository.save(brand); } @Transactional(rollbackOn = Exception.class) public void removeBrand(long id) { repository.deleteById(id); } }
Controller 统一使用 Restful 风格,每个接口包括参数,都打上 Swagger 的 annotation,便于生成接口文档。
@Api(tags = "BrandController", description = "商品品牌管理") @RestController @RequestMapping("/brands") public class BrandController { @Autowired private BrandService service; @ApiOperation(value = "获取所有品牌列表") @GetMapping public GenericResponse<List<BrandDto>> findAllBrands() { return success(service.findAllBrands()); } @ApiOperation(value = "获取品牌") @GetMapping("/{id}") public GenericResponse<BrandDto> getBrand( @ApiParam(value = "品牌Id", required = true) @PathVariable Long id) { return success(service.getBrand(id)); } @ApiOperation(value = "搜索品牌") @GetMapping("/search/{currentPage}/{pageSize}") public GenericResponse<PageResult<BrandDto>> searchBrands( @RequestParam(required = false) String name, @PathVariable Integer currentPage, @PathVariable Integer pageSize) { return success(service.searchBrands(name, currentPage, pageSize)); } @ApiOperation(value = "添加品牌") @PostMapping public GenericResponse addBrand( @ApiParam(value = "品牌信息", required = true) @RequestBody BrandDto brandDto) { service.addBrand(brandDto); return success(); } @ApiOperation(value = "更新品牌") @PutMapping("/{id}") public GenericResponse editBrand( @ApiParam(value = "品牌Id", required = true) @PathVariable Long id, @ApiParam(value = "品牌信息", required = true) @RequestBody BrandDto brandDto) { service.editBrand(id, brandDto); return success(); } @ApiOperation(value = "删除品牌") @DeleteMapping("/{id}") public GenericResponse removeBrand( @ApiParam(value = "品牌Id", required = true) @PathVariable long id) { service.removeBrand(id); return success(); } }
管理后台使用强大的vue-element-admin 做模板。
为每个后台服务创建一个使用 axios 进行远程访问的服务代理。访问服务的基地址就是网关提供的地址。
module.exports = { NODE_ENV: '"development"', ENV_CONFIG: '"dev"', // BASE_API: '"https://api-dev"' BASE_API: '"http://localhost:8889"' }
import request from '@/utils/request'; export function searchBrands(currentPage, pageSize, params) { return request({ url: `/goods/brands/search/${currentPage}/${pageSize}`, method: 'get', params: params }); } export function findAllBrands() { return request({ url: '/goods/brands', method: 'get' }); } export function getBrand(id) { return request({ url: `/goods/brands/${id}`, method: 'get' }); } export function addBrand(params) { return request({ url: '/goods/brands', method: 'post', data: params }); } export function editBrand(id, params) { return request({ url: `/goods/brands/${id}`, method: 'put', data: params }); } export function removeBrand(id) { return request({ url: `/goods/brands/${id}`, method: 'delete' }); }
为了便于管理,每个模块使用单独的配置文件。不需要显示到菜单上的,配置 hidden: true。
import Layout from '@/views/layout/Layout'; const goods = { path: '/goods', component: Layout, redirect: '/goods/brand', name: 'Goods', meta: { title: '商品', icon: 'clipboard' }, children: [ { path: 'brand', name: 'Brand', component: () => import('@/views/goods/brands'), meta: { title: '品牌管理' } }, { path: 'brandAdd', name: 'BrandAdd', component: () => import('@/views/goods/brandAdd'), meta: { title: '添加品牌' }, hidden: true }, { path: 'brandEdit', name: 'BrandEdit', component: () => import('@/views/goods/brandEdit'), meta: { title: '编辑品牌' }, hidden: true } ] }; export default goods;
页面统一建立在 views 目录下,一般的 CRUD,可以模式化地建成列表、新增、更新及一个表单组件。
至此,一个简单的前后端分离的系统就搭建完成了。对于简单的一些业务功能,完全可以套用这样的结构快速完成。
当然,在基础库及系统流程上还需要不断完善增强,包括领域驱动在代码层面的落地,分布式架构下各种问题的应对,后面会一步步改进。