Статьи

Создание мультитенантной архитектуры приложений с использованием Vaadin, Spring, jOOQ и PostreSQL

В этом уроке мы соберем полный стек приложений для мультитенантной архитектуры.

Мы будем использовать:

  • Ваадин и Ваадин-Спринг для пользовательского интерфейса
  • Postgres для хранения
  • Jooq для уровня данных
  • Spring-Security для аутентификации
  • Некоторая клейкая магия для работы с несколькими арендаторами

Вы можете найти полный исходный код для этого примера на Github . Я не буду вдаваться в подробности различных используемых технологий, вместо этого основное внимание будет уделено настройке схемы и реализации логики владения.

Мы в основном начинаем с примера vaadin-spring-security, который вы можете найти в репозитории vaadin4spring .

Этот пример дает нам базовую настройку для приложения vaadin на основе Spring, которое может аутентифицировать пользователя с использованием Spring Security. Для мультитенантности я решил выбрать решение для схемы на каждого арендатора, например:

 

Основная схема содержит определения таблиц для всех таблиц, схемы арендаторов наследуют это определение, чтобы понять, что у нас есть согласованная структура данных для всех арендаторов. Все объекты базы данных управляются с использованием сценариев liquibase. Liquibase — это инструмент рефакторинга базы данных, если вы о нем никогда не слышали, узнайте по адресу http://www.liquibase.org/  Таблица master.tenant содержит информацию о существующих арендаторах и их свойствах подключения к базе данных. Для каждой схемы арендатора соответствующий пользователь базы данных создается с помощью liquibase, который имеет ограниченные привилегии и может получить доступ только к своей собственной схеме арендатора.

Таблица master.user для eg создается следующим образом:

<changeSet author="thomas" id="master-create-table-user">
    <createTable tableName="user" schemaName="master" >
        <column name="id" type="BIGINT" autoIncrement="true"/>
        <column name="user_name" type="varchar(255)"/>
        <column name="password_hash" type="varchar(2048)"/>
        <column name="active" type="boolean" defaultValue="false"/>
    </createTable>
</changeSet>

Таблицы tenant_xx.user определены как:

<changeSet author="thomas" id="${db.schema}-create-child-table-user" >
    <sql>
        create table ${db.schema}.user () INHERITS (master.user);
    </sql>
</changeSet>

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

Скрипты liquibase в примере github создают двух арендаторов (tenant_1 и tenant_2) и по одному администратору каждый. имя пользователя admin для простоты совпадает с именем арендатора, пароль просто «admin». Поэтому, если вы попытаетесь запустить пример, вы можете войти в систему как tenant_1 / tenant_1 / admin.

Теперь у нас есть базовая схема базы данных. Скрипты liquibase нуждались в некоторой настройке для запуска не только на postgres, но и на hsqldb. Это нужно по крайней мере для модульного тестирования, но мы также хотим иметь возможность генерировать наши jooq mapping и dsl без необходимости запуска сервера postgresql на нашем сервере сборки.

При запуске сборки maven создается основная схема на временном экземпляре hsqldb и впоследствии запускается генератор кода jooq. Идея для этого пришла от  http://stanislas.github.io/2014/08/23/liquibase-and-jooq.html

Это все основные вещи, которые нам нужны, прежде чем мы теперь сможем заняться мультитенантной деятельностью. Я хотел иметь четкое разделение арендаторов с точки зрения подключения. Таким образом, у каждого арендатора есть собственный пользователь базы данных с ограниченным доступом и собственный пул соединений. У этого есть некоторые недостатки, поскольку мы, вероятно, могли бы получить довольно много пулов соединений, но преимущество в том, что независимо от того, что вы пытаетесь сделать с соединением арендатора, которое вы получите от весны, вы никогда не сможете прикоснуться к другому арендатору, чем тот, для которого вы аутентифицированы. TenantDataSource — это простая прокси-оболочка, которая направляет вас в соответствующий пул соединений на основе текущего объекта аутентификации Spring. Вот фрагмент:

@Component@Qualifier("no-tx")
public class MultiTenantDataSource implements DataSource {

    @Autowired

    private TenantAuthentication authentication;


    @Autowired

    private TenantDao tenantDao;


    @Autowired

    private TenantHelper tenantHelper;

        

    private Map<String, DataSource> dataSourceMap = new ConcurrentHashMap<>();
    @Override

    public Connection getConnection() throws SQLException {

        DataSource ds = getDataSource();

        return ds.getConnection();

    }

    private DataSource getDataSource() {

        String tenantName = authentication.getTenant(); // will throw if not authenticated

        return  dataSourceMap.computeIfAbsent(tenantName, (key)->{
            Optional<ITenant> tenantOptional = tenantDao.findbyName(tenantName);

            return tenantOptional.map(tenant -> {

                HikariConfig config = tenantHelper.toHikariConfig(tenant);

                return new HikariDataSource(config);

            }).orElseThrow(() -> new IllegalStateException("This should never happen"));

         });
    }

Внедренная TenantAuthentication является прокси-компонентом вокруг объекта Authentication из SpringSecurityContextHolder:

@Bean(name ="currentToken")
TenantAuthentication currentToken() {

    return ProxyFactory.getProxy(TenantAuthentication.class, new MethodInterceptor() {
        @Override

        public Object invoke(MethodInvocation invocation) throws Throwable {

            SecurityContext securityContext = SecurityContextHolder.getContext();

            TenantAuthenticationToken authentication = (TenantAuthenticationToken)securityContext.getAuthentication();

            if (authentication == null) {

                 throw new AuthenticationCredentialsNotFoundException("No auth..");

            }

            return invocation.getMethod().invoke(authentication, invocation.getArguments());

        }

    });

}

Подключение Jooq и Spring с управлением транзакциями — это немного усилий, и я решил это, как описано в  http://www.jooq.org/doc/3.6/manual/getting-started/tutorials/jooq-with-spring/

Последний шаг, который мы должны решить, — это сопоставление схемы jooq со схемой текущего аутентифицированного пользователя. Я решил это снова с помощью прокси-компонента для jooq DSLContext:

@Bean

public DSLContext dsl(){

    return ProxyFactory.getProxy(DSLContext.class, new MethodInterceptor() {

        Map<String, DSLContext> contextMap = new ConcurrentHashMap<>();

        @Override

        public Object invoke(MethodInvocation invocation) throws Throwable {

            String tenant = authentication.getTenant(); // will throw if not authenticated

            DSLContext ctx = contextMap.computeIfAbsent(tenant, (key) ->{ 

                Settings settings = new Settings().withRenderMapping(new RenderMapping().withSchemata(new MappedSchema().withInput("master").withOutput(key)));

                DefaultConfiguration configuration = new DefaultConfiguration();

                configuration.setSQLDialect(SQLDialect.POSTGRES_9_4);

                configuration.setSettings(settings);

                configuration.setConnectionProvider(connectionProvider());

                configuration.setExecuteListenerProvider(new DefaultExecuteListenerProvider(new ExceptionTranslator()));

                return new DefaultDSLContext(configuration);

            });

            return invocation.getMethod().invoke(ctx, invocation.getArguments());

        }

    });

}

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

@Component

public class UserDao {

    /**

     * the dsl context.

     *

     * the context we inject here is actually a proxy bound to the current tenant schema

     * backed by a datasource that has only access to this tenant schema

     */

    @Autowired

    DSLContext dsl;


    public List<IUser> findAll() {

        return dsl.selectFrom(USER).fetchInto(User.class);

    }

}

Наконец нам нужна форма аутентификации наших пользователей. Это делается просто, используя пружинный AuthenticationProvider и представление, которое позволяет нам читать всех пользователей по всем схемам арендатора:

<sql>
    create view "master"."v_user" as
    select n.nspname as tenant, u.user_name, u.password_hash 

    from master.user u left join pg_class p on u.TABLEOID = p.oid 

    left join pg_catalog.pg_namespace n on n.OID =p.relnamespace 

    where u.active=true;
</sql>

Посмотрите пример с github, если вы хотите погрузиться в изящные детали!