Трудозатраты на реализацию «простого» модуля отправки Email в приложении с модульной архитектурой

от автора

На php отправка mail реализуется одной строчкой кода! А на java- нужно 3 недели??!
(из разговоров с разработчиками и менеджерами)


Статья не о том, как отправлять почту на java. Моя цель — показать сложности модульной разработки больших приложений (на примере разработки ERP River).

Итак, задача: реализовать сервис отправки по email (war).

Этапы разработки:

Начнем собственно с отправки

Если не Spring (для небольшого модуля он не нужен), подключаем apache commons-email

        <dependency>             <groupId>org.apache.commons</groupId>             <artifactId>commons-email</artifactId>             <version>1.3</version>         </dependency>

и пишем

        public class MailSender {             public static void sendMail {                 HtmlEmail email = ...                 ?                 email.send();

Позвольте, откуда брать настройки почтового сервера? Хардкодить их, думаю, не придет в голову даже младшему разработчику, поэтому:

Конфигурирование почтового сервера

Надеюсь, у нас уже выполнена общая работа по конфигурированию всей системы, и нам остается только реализовать ее для почтового модуля:

                    ...                     <part key="email">                         <entry key="hostName">smtp.gmail.com</entry>                         <entry key="user">sendmail@mycompany.ru</entry>                         <entry key="password">psw</entry>                         <entry key="smtpPort">465</entry>                         <entry key="useSSL">true</entry>                         <entry key="debug">false</entry>                         <entry key="charset">UTF-8</entry>                         <entry key="useTLS">false</entry>                     </part>                

        public class MailConfig {             public static <T extends Email> T prepareEmail(T email) {                  email.setHostName(hostName);                  email.setSmtpPort(port);                  email.setSSL(useSSL);                  email.setTLS(useTLS);                  email.setDebug(debug);                  email.setAuthenticator(defaultAuthenticator);                  email.setCharset(charset);                  return email;               }

Вызов сервиса

Ага, у нас сервис- как мы хотим его вызывать?
Бизнес хочет интеграцию по веб-сервисам, и нужно еще иметь отправку по простому HTTP GET (например, вызывать напрямую из браузера):

  • Отправка по HTTP GET:
            public class MailServlet extends CommonServlet {             @Override             protected void doProcess(HttpServletRequest request, HttpServletResponse response, Map<String, String> params) throws IOException, ServletException {                  String from = ConfigUtil.getProperty("from", params);                  ...                  MailSender.sendMail(from, to, cc, ..);

  • Реализация вев-сервиса (JAX-WS) посложнее:
            @WebService         @SOAPBinding(style = Style.RPC)         public interface MailService {             @WebMethod             public void sendMail(                 @WebParam(name = "from") String from,                 @WebParam(name = "to") String to,          @WebService(endpointInterface = "mycompany.MailService")         public class MailServiceImpl implements MailService {             @Override             public void sendMail(String from, String to, String cc, String subject, String body, String attachmentUrls) throws StateException {                 MailSender.sendMailAndRecordHistory(from, to, cc, subject, body, ..);             }            

    и mailService.wsdl:

            <definitions ..                 targetNamespace="http://mail.mycompany.com/" name="MailServiceImplService">             <message name="sendMail">                 <part name="from" type="xsd:string"/>                 ...              <portType name="MailService">                 <operation name="sendMail" parameterOrder="from to cc subject body attachmentUrls">                     <input wsam:Action="http://mail.mycompany.com/MailService/sendMailRequest" message="tns:sendMail"/>                 ...              <binding name="MailServiceImplPortBinding" type="tns:MailService">                 <soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="rpc"/>                 <operation name="sendMail">                     <soap:operation soapAction=""/>                 ...             <service name="MailServiceImplService">                 <port name="MailServiceImplPort" binding="tns:MailServiceImplPortBinding">                     <soap:address location="http://mycompany:8080/mail/mailService"/>                 ...            

    Не забываем web.xml (Tomcat)

            <listener>             <listener-class>com.sun.xml.ws.transport.http.servlet.WSServletContextListener</listener-class>         </listener>          <servlet>             <servlet-name>mailService</servlet-name>             <servlet-class>com.sun.xml.ws.transport.http.servlet.WSServlet</servlet-class>             <load-on-startup>1</load-on-startup>         </servlet>         <servlet-mapping>             <servlet-name>mailService</servlet-name>             <url-pattern>/mailService</url-pattern>         </servlet-mapping>          <servlet>             <servlet-name>mailServlet</servlet-name>             <servlet-class>com.mycompany.mail.MailServlet</servlet-class>             <load-on-startup>1</load-on-startup>         </servlet>         <servlet-mapping>             ...            

Выделение mail-client

А как теперь соседнему модулю нашей системы быстро дернуть по веб-сервису наш сервис? Проще всего — выделить maven модуль mail-client, сделать от него зависимым наш mail сервис и разрешить любому модулю — нашему клиенту включать в себя (maven dependency) mail-client:

  • Делаем отдельный maven модуль mail-client и кладем в него mailService.wsdl и interface MailService
            <groupId>com.mycompany</groupId>         <artifactId>mail-client</artifactId>         <name>Mail Client</name>                

  • Кроме того, для полной радости нашего внутреннего клиента делаем MailWSClient:
    вызов соседнего модуля будет совсем простой:
    MailWSClient.sendMail(... 

            public class MailWSClient {             static String mailWsdl;             private static final Service SERVICE;              static {                 URL url = MailWSClient.class.getClassLoader().getResource("mailService.wsdl");                 SERVICE = Service.create(url, new QName("http://mail.mycompany.com/", "MailServiceImplService"));                 // get mail endpoint from config                 mailWsdl = Config.getUrlAsString("mail/mailService?wsdl");             }              public static void sendMail(String from, String to, ..){                         getPort().sendMail(from, ..              private static MailService getPort() {                 MailService port = SERVICE.getPort(MailService.class);                 Map<String, Object> requestContext = ((BindingProvider) port).getRequestContext();                 requestContext.put(BindingProvider.ENDPOINT_ADDRESS_PROPERTY, mailWsdl);                 return port;             }                    

Прикручиваем шаблоны

Эге. В модуле документооборота у нас 52 вида документов. Хорошо б было нашим клиентам дать возможность самим определять шаблон письма. Тем более, что такой сервис (TemplateService) у нас уже реализован.
Сервис шаблонов простой: реализован на jsp, по get ему отправляются ключ и параметры, возвращается готовый текст.

  • Добавляем sendTemplateMail в MailService, MailWSClient, MailServiceImpl, MailSender, mailService.wsdl и MailServlet:
                 sendTemplateMail(.., templateKey, params);                  
  • И реализуем его в MailSender (у нас уже есть удобная обертка MyHttpConnection, реализованная через HttpURLConnection.openConnection())
                static void sendTemplateMail(..., String key, String params) {                 LOGGER.info("Send template mail from ...                 String templateUrl = getUrlAsString("template?type=mail&format=html&key=" + key ...                 MyHttpConnection conn = MyHttpConnection.connect(templateUrl, params);                 if (conn.isOk()) {                    String body = conn.getMsg();                     sendMail(from, to, cc, MailUtil.getSubject(body), body);                 } else {                     throw LOGGER.getStateException(conn.toString(), ExceptionType.TEMPLATE);                 ...                

    Попутно пришлось решить проблему с subject: сервис шаблонов возвращает только тело письма. Шаблон возвращается в формате html, MailUtil выделает из шаблона tiltle и использует его как subject:

            public class MailUtil {             static Pattern MAIL_TITLE = Pattern.compile("<title>(.+)</title>", Pattern.MULTILINE);              static String getSubject(String template) {                 Matcher m = MAIL_TITLE.matcher(template);                 return m.find() ? m.group(1) : null;             }

Отправляем документ

Вообще-то у нас документы. А что, если вызывать нас сервис с id документа? Шаблоны для документов в TemplateService уже есть.

  • Добавляем sendDocMail в MailService, MailWSClient, MailServiceImpl, MailSender, mailService.wsdl и MailServlet.
     sendDocMail(String from, String to, String cc, String key, long docId);                 
  • Опаньки, а у документов есть вложения, которые нужно аттачить к письму.
    К счастью commons-email это легко позволяет, и у нас есть общий maven модуль attach-common, у которого можно попросить список аттачей по docId:
            public class MailSender {             static void sendDocMail(String from, String to, String cc, String key, long docId) throws StateException {                 List<Attach> list = AttachUtil.getList(docId);                 MailSender.sendTemplateMailAndRecordHistory(from, to, cc, key, "objectid=" + docId, MailUtil.formatAttach(list));             }          public class MailUtil {              //  format attaches as             //       ulr1[name1], ulr2[name2], ...              static String formatAttach(List<Attach> list) {                 return Util.collectionToDelimitedString(list, new Presentable<Attach>() {                     @Override                         public String toString(Attach attach) {                         return AttachConfig.downloadUrl + attach.getUuid() + '[' + attach.getName() + ']';                     }        

Отказоустойчивость

А если сервер временно недоступен? Нужно сохранять историю в базе и делать доталкиватель… Заодно решим проблему отправки письма пользователю по назначению на него задачи из BPM — ее можно будет реализовать через триггер в базе: вставлять в таблицу строчку TODO. Как side effect имеем историю отправки наших сообщений, можно потом сверху накрутить ui ну и просто SQL запросы к таблице поделать.
Хорошо, что у нас уже есть механизм сканирования — нужна просто еще одна ее реализация.

  • Делаем в базе таблицу mail_action
            CREATE TABLE hist.mail_action (             id SERIAL,             _from TEXT,             _to TEXT NOT NULL,             _cc TEXT,             subject TEXT,             body TEXT,             attachmenturls TEXT,             state TEXT NOT NULL,             date TIMESTAMP(0) WITHOUT TIME ZONE,             key reference.ui_key,             params TEXT         );
  • Добавляем в конфигурацию интервалы сканирования
            <entry key="scanTodoInterval">30</entry>         <entry key="scanFailInterval">600</entry>    

    scanTodoInterval = ConfigUtil.getInt(SCAN_TODO_INTERVAL, mailProps, 60);  // default 60 sec scanFailInterval = ConfigUtil.getInt(SCAN_FAIL_INTERVAL, mailProps, 600); // default 10 min    

    Реализуем в MailSender запись истории отправки в базу вместе с состоянием (OK или Exception).
    Сканируем таблицу mail_action и на основе состояния state (TODO, EmailException) отсылаем письмо

            <listener>             <listener-class>com.mycompany.common.web.SchedulerListener</listener-class>         </listener>    

            public class MailWebScanner implements WebScheduler {             private final MailScanner todoScanner = new MailScanner("TODO");             private final MailScanner failScanner = new MailScanner("org.apache.commons.mail.EmailException");              @Override             public void activate(ServletContext servletContext) {                 todoScanner.startScanning(MailConfig.scanTodoInterval);                 failScanner.startScanning(MailConfig.scanFailInterval);             }              @Override             public void deactivate() {                 todoScanner.deactivate();                 failScanner.deactivate();             }              @Override             public void shutdown() {                 AsyncExecutor.shutdown();             }         }          public class MailScanner extends Scanner {             private static final BeanListHandler<MailBean> HANDLER = new BeanListHandler<MailBean>(MailBean.class);             private final String startWith;              public MailScanner(String startWith) {                 this.startWith = startWith;             }              void startScanning(int interval) {                 activate(new Runnable() {                     @Override                     public void run() {                         for (MailBean mail : getMailToSend()) {                             MailSender.sendTemplateMailAndRecordHistory(                         }                     }                 }, interval, false);             }             ...              List<MailBean> getMailToSend() {                 return SqlUtil.executeQuery("select * from hist.mail_action where state like '" + startWith + "%'", HANDLER);             ...    

Для тех, кто не любит ждать: асинхронность

Так как наш сервис теперь устойчив к отказам, дадим возможность клиентам нашего веб-сервиса не ждать ответа. Вместо того, чтобы дублировать все методы серсвиса с постфиксом Async и аннотацией @OneWay добавим в вызовы MailWSClient флаг async и вызов AsyncExecutor (нашей обертки поверх ScheduledThreadPoolExecutor):

        public class MailWSClient {             public static void sendMail(final String from, final String to, final String cc, final String subject, final String body, final String attachmentUrls, boolean async) throws StateException {                 send(new Runnable() {                     @Override                     public void run() {                        getPort().sendMail(mask(from), mask(to), mask(cc), mask(subject), mask(body), mask(attachmentUrls));                     }                 }, async);             }              public static void sendTemplateMail(final String from, final String to, final String cc, final String key, final String params, final String attachmentUrls, boolean async) throws StateException {                 ...               public static void sendDocMail(final String from, final String to, final String cc, final String key, final long docId, boolean async) throws StateException {                 ...              private static void send(Runnable task, boolean async) {                 if (async) {                    AsyncExecutor.submit(task);                 } else {                    task.run();                 }              }

Чиним вложения картинок

Олично, все работает! Наконец, можно фиксить баги — картинки в письме не видны снаружи нашего интранета… Ведь они у нас в шаблонах заданы через <img src=«наши внутренние ресурсы», естественно, во всем остальном мире их не увидишь.

Делаем их встроенными:

        public class MailSender {             static void sendMailAndRecordHistory(String from, String to, String cc, String key, String params, String attachmentUrls, long docId) throws StateException {                 ...                 String embedImgBody = MailUtil.embedImg(body, email);          public class MailUtil {             static final Pattern HTML_URL = Pattern.compile("<img src=(?:\"|')(.+)(?:\"|')", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE);             public static String embedImg(String body, final HtmlEmail email) throws EmailException {                 return StringUtil.resolveReplacement(body, HTML_URL, new Presentable<Matcher>() {                     @Override                     public String toString(Matcher matcher) {                         String url = matcher.group(1);                         cid = email.embed(url, UUID.nameUUIDFromBytes(url.getBytes()).toString());                     }                     return "<img src=\"cid:" + cid + "\"";                  ...                

Отправляем встроенные (data:image/png;base64,encoded_img) большие картинки

Новая задумка бизнеса — по ошибке из браузера клиента отправлять на support mail скриншот экрана.
Решение на UI найдено — ход за нами. Для сервиса шаблонов пишем шаблон error_mail.jsp

            <%@page pageEncoding="UTF-8" %>             <html>             <head>                 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">                 <title>Error Report</title>             </head>             <body>             <h2>Error Report from '${user}'</h2>             <b>Message:</b>                 <pre>                 ${message}                 </pre>             <b>Screenshot:</b><br>             <img src="${screenshot}">             </body>             </html>         

Параметры шаблона — exception message и base64_encoded_screenshot — отправляются в TemplateService из нашего сервиса. У нас проблемы: наша самописная обертка MyHttpConnection не может через GET отправлять base64_encoded_screenshot. Приходиться делать POST и еще раз делать URLEncoder.encode из за проблем с "+". Кроме того- в пришедшей почте inline картинка не видна 🙁 Что ж, придется ее также делать вложением:

        public class MailUtil {             static Pattern DATA_PROTOCOL = Pattern.compile("^data:(.+);(.+),");              public static String embedImg(String body, final HtmlEmail email) throws EmailException {                 return StringUtil.resolveReplacement(body, HTML_URL, new Presentable<Matcher>() {                 @Override                 public String toString(Matcher matcher) {                     String url = matcher.group(1);                     String cid;                     try {                     Matcher m = DATA_PROTOCOL.matcher(url);                     if (m.find()) {                         final String cType = m.group(1);                         final String encoding = m.group(2);                         final String content = url.substring(m.toMatchResult().end());                          cid = email.embed(new javax.activation.DataSource() {                             @Override                             public InputStream getInputStream() throws IOException {                                 try {                                     return javax.mail.internet.MimeUtility.decode(new ByteArrayInputStream(IOUtil.getBytes(content)), encoding);                                 } catch (MessagingException e) {                                     throw LOGGER.getIllegalStateException("Image encoding failed", e);                                 }                             }                             // empty realization for other javax.activation.DataSource methods                             ...                         }, UUID.randomUUID().toString());                     } else {                         cid = email.embed(url, UUID.nameUUIDFromBytes(url.getBytes()).toString());                     }                     return "<img src=\"cid:" + cid + "\"";             ...         

Финальная точка: безопасность

Однако, любой пользователь отправляет get запрос из браузера- и получает письмо с совершенно секретным документом. Нехорошо. Необходимо прикрутить проверку доступа у пользователя к документу с переданным docId и вообще проверить: если запрос пришел по по get, залогинен ли пользователь в нашу систему.
Из-за того, что страница с логином уже была сделана и вокруг нее много что вертелось, а точка входа в систему у нас одна, я сделал проверку через REST и куки уровня домена с доверием к серверным запросам между самими модулями, но это уже — отдельная статья.

Итоги простой задачи отправки почты:

В результате получилось 2 maven модуля с классами (не считая инфраструктуры типа конфигурации, вложений, шаблонов, общей части и JUnit тестов)

  • mail-client
    • MailService: интерфейс (sendMail, sendTemplateMail, sendDocMail)
    • MailWSClient: обертка к клиенту, выставляющая endPoint из конфигурации
    • mailService.wsdl

  • mail-service
    • MailSender: собственно отправка
    • MailServiceImpl: имплементация веб-сервиса, делегирование в MailSender
    • MailServlet: сервлет для обработки HTTP GET
    • MailBean: бин для чтения строки из базы через commons-dbutils
    • MailConfig: конфигурация
    • MailScanner: сканирование таблицы по состоянию отправки
    • MailWebScanner: реализация листенера для нашего сервиса, запускающего 2 сканнера MailScanner
    • MailUtil: утильные методы
    • EmailExceptionHandler: обработка exceptions, не доталкивается AddressException
    • sun-jaxws.xml, web.xml

Терпеливый читатель, дошедший до конца статьи может сам сравнить количество трудозатрат на «задачу отправку почты» и полученную реализацию. Спасибо за внимание.


Ссылки:

ссылка на оригинал статьи http://habrahabr.ru/post/166727/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *