1. 程式人生 > >SpringBoot+Dubbo分散式SOA專案骨架搭建(二)

SpringBoot+Dubbo分散式SOA專案骨架搭建(二)

SpringBoot+Dubbo分散式SOA專案骨架搭建

專案介紹

涉及技術

  • SpringBoot+多環境配置(dev,proc,test)
  • Dubbo
  • SpringMVC
  • Spring
  • MyBaits
  • MyBatis Generator
  • MyBatis PageHelper
  • Druid
  • Lombok
  • JWT
  • Spring Security
  • JavaMail
  • Thymeleaf
  • HttpClient
  • FileUpload
  • Spring Scheduler
  • Hibernate Validator
  • Redis Cluster
  • MySQL主從複製,讀寫分離
  • Spring Async
  • Spring Cache
  • Swagger
  • Spring Test
  • Spring Actuator
  • Logback+Slf4j多環境日誌
  • i18n
  • Maven Multi-Module

功能點

使用者模組

- 獲取圖片驗證碼
- 登入:解決重複登入問題
- 註冊
- 分頁查詢使用者資訊
- 修改使用者資訊

站內信模組

- 一對一發送站內信
- 管理員廣播
- 讀取站內信(未讀和已讀)
- 一對多傳送站內信

檔案模組

- 檔案上傳
- 檔案下載

郵件模組

- 單獨傳送郵件
- 群發郵件
- Thymeleaf郵件模板

安全模組

- 註解形式的許可權校驗
- 攔截器

實現細節

參考資料

講解Dubbo相關知識最全面的是阿里巴巴的使用者指南。
Dubbo使用者指南
另外Dubbo管控平臺的搭建參考下面這個連結。
Dubbo管控平臺

基礎架構

這裡寫圖片描述
分為Registry(比如Zookeeper註冊中心),Monitor(部署在Tomcat中,實時獲取Provider和Consumer等的狀態),Provider(比如WebProject),Consumer(Services)。

安裝Dubbo管控臺

主機一臺,前提是安裝了JDK和Tomcat。
1. 將war(從網路上下載即可)包放到tomcat的webapps下面2
2. . 執行tomcat
/usr/local/apache-tomcat-8.5.20/bin/startup.sh
3. 修改/usr/local/apache-tomcat-8.5.20/webapps/dubbo-admin-2.8.4/WEB-INF/dubbo.properties

dubbo.registry.address=zookeeper://192.168.1.118:2181
dubbo.admin.root.password=root
dubbo.admin.guest.password=guest

192.168.1.118是zookeeper主機的IP地址。
4. 啟動zookeeper
5. 重啟tomcat
一定要先啟動zookeeper啟動後再去啟動tomcat!
6. 訪問
http://192.168.1.121:8080/dubbo-admin-2.8.4
前提是主機的防火牆關閉。

服務拆分

  1. 表:避免出現A服務關聯B服務的表的資料操作;服務一旦劃分了,那麼資料庫即便沒分開,也要當成db表分開了來進行編碼;否則AB服務難以進行垂直拆庫
  2. 避免服務耦合度高,依賴呼叫;如果出現,考慮服務調優。
  3. 避免分散式事務,不要拆分過細。
  4. 介面儘可能大粒度,介面中的方法不要以業務流程來,這個流程儘量在方法邏輯中呼叫,介面應代表一個完整的功能對外提供;
  5. 介面應以業務為單位,業務相近的進行抽象,避免介面數量爆炸
  6. 引數先做校驗,在傳入介面。
  7. 要做到在設計介面時,已經確定這個介面職責、預測呼叫頻率。
  8. 個人經驗總結:
    比如模組有user、mail等,原本每個模組下有dao、service、web等包,現在是將每個模組作為一個子專案,統一命名為biz(bussiness的簡寫)。
    • biz-service(介面實現類)
    • biz-api(domain實體類、列舉類、異常類、介面)
    • biz-web(RESTful Controller,消費Service)
    • web依賴於api
    • service依賴於api
    • service和web沒有依賴

專案結構

這裡寫圖片描述
這裡有三個子專案(email郵件服務、mail站內信、user使用者),每個專案都有api和service。每個service都是Dubbo的Provider,同時也有可能是Dubbo的Consumer。web子專案一定是Dubbo的Consumer。

程式碼示例

以user模組為例:

user-api

UserService


/**
 * Created by SinjinSong on 2017/4/27.
 */
public interface UserService {
    UserDO findByUsername(String username);
    UserDO findByPhone(String phone);
    UserDO findById(Long id);
    void save(UserDO userDO);
    void update(UserDO userDO);
    PageInfo<UserDO> findAll(int pageNum, int pageSize);
    String findAvatarById(Long id);
    UserDO findByEmail(String email);
    void resetPassword(Long id, String newPassword);
    List<Long> findAllUserIds();
}

user-service

UserServiceImpl

/**
 * Created by SinjinSong on 2017/4/27.
 */
@Service
@Slf4j
public class UserServiceImpl implements UserService {
    @Autowired
    private UserDOMapper userDOMapper;
    @Autowired
    private RoleDOMapper roleDOMapper;

    @Override
    @Cacheable("UserDO")
    @Transactional(readOnly = true)
    public UserDO findByUsername(String username) {
        return userDOMapper.findByUsername(username);
    }

    @Override
    @Cacheable("UserDO")
    @Transactional(readOnly = true)
    public UserDO findByPhone(String phone) {
        return userDOMapper.findByPhone(phone);
    }

    @Override
    @Cacheable("UserDO")
    @Transactional(readOnly = true)
    public UserDO findById(Long id) {
        return userDOMapper.selectByPrimaryKey(id);
    }

    @Override
    @Transactional
    @CacheEvict(value = "UserDO",allEntries = true)
    public void save(UserDO userDO) {
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        //對密碼進行加密
        userDO.setPassword(passwordEncoder.encode(userDO.getPassword()));
        userDO.setRegTime(LocalDateTime.now());
        //設定使用者狀態為未啟用
        userDO.setUserStatus(UserStatus.UNACTIVATED);
        userDOMapper.insert(userDO);
        //新增使用者的角色,每個使用者至少有一個user角色
        long roleId = roleDOMapper.findRoleIdByRoleName("ROLE_USER");
        roleDOMapper.insertUserRole(userDO.getId(),roleId);
    }

    @Override
    @Transactional
    @CacheEvict(value = "UserDO",allEntries = true)
    public void update(UserDO userDO) {
        userDOMapper.updateByPrimaryKeySelective(userDO);
    }

    @Override
    @Transactional
    @CacheEvict(value = "UserDO",allEntries = true)
    public void resetPassword(Long id,String newPassword) {
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        UserDO userDO = new UserDO();
        userDO.setId(id);
        userDO.setPassword(passwordEncoder.encode(newPassword));
        userDOMapper.updateByPrimaryKeySelective(userDO);
    }

    @Override
    public List<Long> findAllUserIds() {
        return userDOMapper.findAllUserIds();
    }


    @Override
    public PageInfo<UserDO> findAll(int pageNum, int pageSize) {
        return userDOMapper.findAll(pageNum,pageSize).toPageInfo();
    }

    @Override
    public String findAvatarById(Long id) {
        return userDOMapper.findAvatarById(id);
    }

    @Override
    public UserDO findByEmail(String email) {
        return userDOMapper.findByEmail(email);
    }
}

UserApplication

/**
 * Created by SinjinSong on 2017/9/21.
 */
@SpringBootApplication
@EnableConfigurationProperties
@ComponentScan({"cn.sinjinsong"})
@ImportResource("classpath:dubbo.xml")
@Slf4j
public class UserApplication implements CommandLineRunner {

    public static void main(String[] args) throws IOException {
        SpringApplication app = new SpringApplication(UserApplication.class);
        app.setWebEnvironment(false);
        app.run(args);
        synchronized (UserApplication.class) {
            while (true) {
                try {
                    UserApplication.class.wait();
                } catch (Throwable e) {
                }
            }
        }
    }

    @Override
    public void run(String... args) throws Exception {
    }
}

dubbo.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:dubbo="http://code.alibabatech.com/schema/dubbo"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://code.alibabatech.com/schema/dubbo
        http://code.alibabatech.com/schema/dubbo/dubbo.xsd
        ">
    <bean id="userService"
          class="cn.sinjinsong.skeleton.service.impl.UserServiceImpl"/>
    <!-- 提供方應用資訊,用於計算依賴關係 -->
    <dubbo:application name="user_provider"/>
    <!-- 使用zookeeper註冊中心暴露服務地址 -->
    <dubbo:registry protocol="zookeeper" address="192.168.1.118:2181,192.168.1.119:2181,192.168.1.120:2181"/>
    <!-- 用dubbo協議在20880埠暴露服務 -->
    <dubbo:protocol name="dubbo" port="20880" serialization="java"/>
    <!-- 宣告需要暴露的服務介面 -->
    <dubbo:service interface="cn.sinjinsong.skeleton.service.UserService" version="1.0.0"
                   ref="userService"/>
</beans>

web

UserController

/**
 * Created by SinjinSong on 2017/4/27.
 */
@RestController
@RequestMapping("/users")
@Api(value = "users", description = "使用者API")
@Slf4j
public class UserController {
    @Autowired
    private UserService service;
    @Autowired
    private VerificationManager verificationManager;
    @Autowired
    private EmailService emailService;
    @Autowired
    private AuthenticationProperties authenticationProperties;

    /**
     * mode 支援id、username、email、手機號
     * 只有管理員或自己才可以查詢某使用者的完整資訊
     *
     * @param key
     * @param mode id、username、email、手機號
     * @return
     */
    @RequestMapping(value = "/query/{key}", method = RequestMethod.GET)
    @PostAuthorize("hasRole('ADMIN') or (returnObject.username ==  principal.username)")
    @ApiOperation(value = "按某屬性查詢使用者", notes = "屬性可以是id或username或email或手機號", response = UserDO.class, authorizations = {@Authorization("登入許可權")})
    @ApiResponses(value = {
            @ApiResponse(code = 401, message = "未登入"),
            @ApiResponse(code = 404, message = "查詢模式未找到"),
            @ApiResponse(code = 403, message = "只有管理員或使用者自己能查詢自己的使用者資訊"),
    })
    public UserDO findByKey(@PathVariable("key") @ApiParam(value = "查詢關鍵字", required = true) String key, @RequestParam("mode") @ApiParam(value = "查詢模式,可以是id或username或phone或email", required = true) String mode) {

        QueryUserHandler handler = SpringContextUtil.getBean("QueryUserHandler", StringUtils.lowerCase(mode));
        if (handler == null) {
            throw new QueryUserModeNotFoundException(mode);
        }
        UserDO userDO = handler.handle(key);
        if (userDO == null) {
            throw new UserNotFoundException(key);
        }
        return userDO;
    }

    @ResponseStatus(HttpStatus.CREATED)
    @RequestMapping(method = RequestMethod.POST)
    @ApiOperation(value = "建立使用者,為使用者傳送驗證郵件,等待使用者啟用,若24小時內未啟用需要重新註冊", response = Void.class)
    @ApiResponses(value = {
            @ApiResponse(code = 409, message = "使用者名稱已存在"),
            @ApiResponse(code = 400, message = "使用者屬性校驗失敗")
    })
    public void createUser(@RequestBody @Valid @ApiParam(value = "使用者資訊,使用者的使用者名稱、密碼、暱稱、郵箱不可為空", required = true) UserDO user, BindingResult result) {
        log.info("{}", user);
        if (isUsernameDuplicated(user.getUsername())) {
            throw new UsernameExistedException(user.getUsername());
        } else if (result.hasErrors()) {
            throw new ValidationException(result.getFieldErrors());
        }

        //生成郵箱的啟用碼
        String activationCode = UUIDUtil.uuid();
        //儲存使用者
        service.save(user);

        verificationManager.createVerificationCode(activationCode, String.valueOf(user.getId()), authenticationProperties.getActivationCodeExpireTime());
        log.info("{}     {}", user.getEmail(), user.getId());
        //傳送郵件
        Map<String, Object> params = new HashMap<>();
        params.put("id", user.getId());
        params.put("activationCode", activationCode);
        emailService.sendHTML(user.getEmail(), "activation", params, null);
    }


    @RequestMapping(value = "/{id}/avatar", method = RequestMethod.GET)
    @ApiOperation(value = "獲取使用者的頭像圖片", response = Byte.class)
    @ApiResponses(value = {
            @ApiResponse(code = 404, message = "檔案不存在"),
            @ApiResponse(code = 400, message = "檔案傳輸失敗")
    })
    public void getUserAvatar(@PathVariable("id") Long id, HttpServletRequest request, HttpServletResponse response) {
        String relativePath = service.findAvatarById(id);
        FileUtil.download(relativePath, request.getServletContext(), response);
    }

    @RequestMapping(value = "/{id}/activation", method = RequestMethod.GET)
    @ApiOperation(value = "使用者啟用,前置條件是使用者已註冊且在24小時內", response = Void.class)
    @ApiResponses(value = {
            @ApiResponse(code = 401, message = "未註冊或超時或啟用碼錯誤")
    })
    public void activate(@PathVariable("id") @ApiParam(value = "使用者Id", required = true) Long id, @RequestParam("activationCode") @ApiParam(value = "啟用碼", required = true) String activationCode) {
        UserDO user = service.findById(id);
        //獲取Redis中的驗證碼
        if (!verificationManager.checkVerificationCode(activationCode, String.valueOf(id))) {
            verificationManager.deleteVerificationCode(activationCode);
            throw new ActivationCodeValidationException(activationCode);
        }
        user.setUserStatus(UserStatus.ACTIVATED);
        verificationManager.deleteVerificationCode(activationCode);
        service.update(user);
    }

    // 更新
    @RequestMapping(method = RequestMethod.PUT)
    @PreAuthorize("#user.username == principal.username or hasRole('ADMIN')")
    @ApiOperation(value = "更新使用者資訊", response = Void.class, authorizations = {@Authorization("登入許可權")})
    @ApiResponses(value = {
            @ApiResponse(code = 401, message = "未登入"),
            @ApiResponse(code = 404, message = "使用者屬性校驗失敗"),
            @ApiResponse(code = 403, message = "只有管理員或使用者自己能更新使用者資訊"),

    })
    public void updateUser(@RequestBody @Valid @ApiParam(value = "使用者資訊,使用者的使用者名稱、密碼、暱稱、郵箱不可為空", required = true) UserDO user, BindingResult result) {
        if (result.hasErrors()) {
            throw new ValidationException(result.getFieldErrors());
        }
        service.update(user);
    }

    @RequestMapping(value = "/{key}/password/reset_validation", method = RequestMethod.GET)
    @ApiOperation(value = "傳送忘記密碼的郵箱驗證", notes = "屬性可以是id,sername或email或手機號", response = UserDO.class)
    public void forgetPassword(@PathVariable("key") @ApiParam(value = "關鍵字", required = true) String key, @RequestParam("mode") @ApiParam(value = "驗證模式,可以是username或phone或email", required = true) String mode) {
        UserDO user = findByKey(key, mode);
        //user 一定不為空
        String forgetPasswordCode = UUIDUtil.uuid();
        verificationManager.createVerificationCode(forgetPasswordCode, String.valueOf(user.getId()), authenticationProperties.getForgetNameCodeExpireTime());
        log.info("{}   {}", user.getEmail(), user.getId());
        //傳送郵件
        Map<String, Object> params = new HashMap<>();
        params.put("id", user.getId());
        params.put("forgetPasswordCode", forgetPasswordCode);
        emailService.sendHTML(user.getEmail(), "forgetPassword", params, null);
    }


    @RequestMapping(value = "/{id}/password", method = RequestMethod.PUT)
    @ApiOperation(value = "忘記密碼後可以修改密碼")
    public void resetPassword(@PathVariable("id") Long id, @RequestParam("forgetPasswordCode") @ApiParam(value = "驗證碼", required = true) String forgetPasswordCode, @RequestParam("password") @ApiParam(value = "新密碼", required = true) String password) {
        //獲取Redis中的驗證碼
        if (!verificationManager.checkVerificationCode(forgetPasswordCode, String.valueOf(id))) {
            verificationManager.deleteVerificationCode(forgetPasswordCode);
            throw new ActivationCodeValidationException(forgetPasswordCode);
        }
        verificationManager.deleteVerificationCode(forgetPasswordCode);
        service.resetPassword(id, password);
    }

    @RequestMapping(value = "/{username}/duplication", method = RequestMethod.GET)
    @ApiOperation(value = "查詢使用者名稱是否重複", response = Boolean.class)
    @ApiResponses(value = {@ApiResponse(code = 401, message = "未登入")})
    public boolean isUsernameDuplicated(@PathVariable("username") String username) {
        if (service.findByUsername(username) == null) {
            return false;
        }
        return true;
    }

    @RequestMapping(method = RequestMethod.GET)
    @PreAuthorize("hasRole('ADMIN')")
    @ApiOperation(value = "分頁查詢使用者資訊", response = PageInfo.class, authorizations = {@Authorization("登入許可權")})
    @ApiResponses(value = {@ApiResponse(code = 401, message = "未登入")})
    public PageInfo<UserDO> findAllUsers(@RequestParam(value = "pageNum", required = false, defaultValue = PageProperties.DEFAULT_PAGE_NUM) @ApiParam(value = "頁碼,從1開始", defaultValue = PageProperties.DEFAULT_PAGE_NUM) Integer pageNum, @RequestParam(value = "pageSize", required = false, defaultValue = PageProperties.DEFAULT_PAGE_SIZE) @ApiParam(value = "每頁記錄數", defaultValue = PageProperties.DEFAULT_PAGE_SIZE) Integer pageSize) {
        return service.findAll(pageNum, pageSize);
    }
}

WebApplication

/**
 * Created by SinjinSong on 2017/9/22.
 */
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
@EnableConfigurationProperties
@ComponentScan({"cn.sinjinsong"})
@ImportResource("classpath:dubbo.xml")
@Slf4j
public class WebApplication extends SpringBootServletInitializer {

    public static void main(String[] args) {
        SpringApplication.run(WebApplication.class, args);
        synchronized (WebApplication.class) {
            while (true) {
                try {
                    WebApplication.class.wait();
                } catch (Throwable e) {
                }
            }
        }
    }
}

dubbo.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:dubbo="http://code.alibabatech.com/schema/dubbo"
       xsi:schemaLocation="http://www.springframework.org/schema/beans  
        http://www.springframework.org/schema/beans/spring-beans.xsd  
        http://code.alibabatech.com/schema/dubbo  
        http://code.alibabatech.com/schema/dubbo/dubbo.xsd  
        ">
    <!-- 消費者應用資訊,用於提供依賴關係 -->
    <dubbo:application name="web_consumer"/>
    <!-- 註冊地址,用於消費者尋找服務 -->
    <dubbo:registry protocol="zookeeper" address="192.168.1.118:2181,192.168.1.119:2181,192.168.1.120:2181"/>
    <dubbo:consumer timeout="5000"/>
    <!-- 引用的服務 -->
    <dubbo:reference id="userService" interface="cn.sinjinsong.skeleton.service.UserService" version="1.0.0"/>
    <dubbo:reference id="mailService" interface="cn.sinjinsong.skeleton.service.MailService" version="1.0.0"/>
    <dubbo:reference id="emailService" interface="cn.sinjinsong.skeleton.service.EmailService" version="1.0.0"/>

</beans>

總結

在專案中引入Dubbo有幾種方式,最常用的是基於XML和基於註解的。就個人感覺而言基於XML雖然有點low,但是對程式碼的侵入性很小,可以說完全不需要對程式碼有任何改變,只需要在dubbo.xml寫bean即可,而且官方的使用者手冊中的示例都是以XML為例的,所以就通用性而言XML更好一些。

spring-boot-starter-dubbo

我嘗試了阿里的一位技術員擴充套件的spring-boot-starter-dubbo,但是不知什麼原因Provider不能連線Zookeeper,而Consumer可以,後來換了原生的API就都可以連線了。

序列化

dubbo預設的序列化方式是hession2,但是它不支援Java8的時間日期API(LocalDateTime、LocalDate等)的序列化,在序列化時會丟擲StackOverflowError異常。使用Java預設的序列化方式不會出現這種問題。

<dubbo:protocol name="dubbo" port="20880" serialization="java"/>

其他序列化方式

dubbo=com.alibaba.dubbo.common.serialize.support.dubbo.DubboSerialization
hessian2=com.alibaba.dubbo.common.serialize.support.hessian.Hessian2Serialization
java=com.alibaba.dubbo.common.serialize.support.java.JavaSerialization
compactedjava=com.alibaba.dubbo.common.serialize.support.java.CompactedJavaSerialization
json=com.alibaba.dubbo.common.serialize.support.json.JsonSerialization
fastjson=com.alibaba.dubbo.common.serialize.support.json.FastJsonSerialization
nativejava=com.alibaba.dubbo.common.serialize.support.nativejava.NativeJavaSerialization
kryo=com.alibaba.dubbo.common.serialize.support.kryo.KryoSerialization
fst=com.alibaba.dubbo.common.serialize.support.fst.FstSerialization
jackson=com.alibaba.dubbo.common.serialize.support.json.JacksonSerialization

事務

我做的這個專案骨架距離真正的分散式專案尚有一些區別,最主要的是應用拆分與資料庫拆分是同時進行的。資料庫的水平拆分可以減輕單庫的壓力,通常情況下單個應用和它所依賴的表是放在同一個機器上的,而我現在是把資料庫集中到某幾臺機器上了。
資料庫的拆分一定會產生分散式事務的問題,而如果資料庫沒有進行水平拆分,都放在同一臺機器上,各個應用都連線同一個資料庫,是不存在分散式事務的問題的。分散式事務目前Dubbo本身尚不支援,可以通過一些其他的方式比如2PC、TCC、MQ等解決。
另外資料庫拆分後的訪問本身就比較麻煩,需要依賴於一些資料庫的中介軟體比如MyCat等,目前尚未加入到本專案骨架中。

開源

三部曲
這裡是SpringBoot專案骨架的三部曲,發展歷程是:單體式-元件分散式-SOA,供不同階段的程式設計師學習。
單體式
元件分散式
SOA

展望

學習一下資料庫中介軟體,將資料庫進行水平拆分,考慮使用MQ、TCC等來解決分散式事務問題。
未來幾個月可能要把學習的重點放在基礎和原理上了,比如Spring的原始碼、模仿造輪子、刷演算法題等。大概以後發的部落格更側重於原理方面而非應用了。
歡迎繼續關注本專案系列的發展完善。