MultiTenants, autorização e CodeSplitting
Técnicas para um frontend dinâmico e com controle de acesso
Introdução
Minha última experiência tem sido bastante peculiar. Acabei tendo de usar/desenvolver:
- Hooks (agora é padrão)
- Redux (de forma dinâmica)
- Code Splitting
- ErrorBoundary (controle de erros, React)
- Roteamento dinâmico
- Controle de acesso
Ficou bastante coisa, um pouco complexo e com certeza um caso de over engineering, mas foi a forma que mais fez sentido e a que na minha cabeça ficou da melhor forma.
Vamos por parte explicando o setup, mas se quiser ver o resultado, é só ir lá no meu github.
create-react-app
Como é um projeto em React, utilizaremos o create-react-app
, a forma mais simples de começar um projeto React
Lembrando que Typescript é opcional, então pode omitir a flag --typescript
create-react-app react-app-multitenant --typescript
Como esse projeto é um fork da empresa, então o exemplo no git está com eject
, mas isso não é obrigatório e não vai interferir muito para você. Caso queira utilizar o import absoluto por dentro do seu projeto, você pode utilizar essa mesma configuração do github que já está tudo certo. Vale lembrar também que o build desse projeto está gerando os arquivos sem o hash para evitar cachê.
Menu, Rotas e Code Splitting
Menu
A criação do menu, das rotas e a técnica de code splitting ficaram quase que atreladas ao mesmo objeto. Olhando o menu.ts você irá ver uma property no objeto chamada component
que é uma função (profile: string, tenant?: string) => Promise<any>
. Melhor observar a tipagem dos meus itens do menu:
/*
O menu é constituído de um item principal que pode ou não ter sub itens.
A property component é onde iremos fazer o import() para fazer funcionar
o code splitting. Nele dizemos o componente e a rota (property path) que
iremos renderizar para o usuário
*/
export type type MenuItem = {
component?: (profile: string, tenant?: string) => Promise<any>;
key: string;
path: string;
icon: string;
title: string;
tenants: string[];
subItems: MenuSubItem[];
tenantEnv?: string;
allowedProfiles: string[];
}
MenuItem = {
component?: ((profile: string, tenant?: string) => Promise<any>) | undefined
component?: (profile: string
profile: string, tenant: string | undefined
tenant?: string) => interface Promise<T>
Represents the completion of an asynchronous operationPromise<any>;
key: string
key: string;
path: string
path: string;
icon: string
icon: string;
title: string
title: string;
tenants: string[]
tenants: string[];
subItems: MenuSubItem[]
subItems: type MenuSubItem = {
component?: (profile: string, tenant?: string) => Promise<any>;
key: string;
path: string;
icon: string;
title: string;
tenants: string[];
tenantEnv?: string;
allowedProfiles: string[];
}
MenuSubItem[];
tenantEnv?: string | undefined
tenantEnv?: string;
allowedProfiles: string[]
allowedProfiles: string[];
};
// Um sub item não pode ter sub itens, então iremos omitir este tipo
export type type MenuSubItem = {
component?: (profile: string, tenant?: string) => Promise<any>;
key: string;
path: string;
icon: string;
title: string;
tenants: string[];
tenantEnv?: string;
allowedProfiles: string[];
}
MenuSubItem = type Omit<T, K extends keyof any> = { [P in Exclude<keyof T, K>]: T[P]; }
Construct a type with the properties of T except for those in type K.Omit<type MenuItem = {
component?: (profile: string, tenant?: string) => Promise<any>;
key: string;
path: string;
icon: string;
title: string;
tenants: string[];
subItems: MenuSubItem[];
tenantEnv?: string;
allowedProfiles: string[];
}
MenuItem, "subItems">;
Bom, mas porque component
é uma função que recebe profile
e tenant
? Esses parâmetros são para que nós possamos construir o path do nosso componente de acordo com o perfil logado e o tenant acessado, assim iremos conseguir acessar nosso componente no diretório referente ao tenant e o arquivo de acordo com o perfil, por exemplo ../modules/users/xpto/admin.page
.
Com isso, temos parte do nosso code splitting funcionando, agora precisamos de um pouco de código para a criação das nossas rotas e o Suspense para o lazy loading dos componentes.
Para definir as rotas de forma dinâmica, dado um tenant e um perfil logado, foi necessário um hook
Rotas
// Apenas um tipo para forçar o array de rotas ter os itens necessários para o react-router
export type RouterConstruct = {
path: string;
component: React.LazyExoticComponent<React.ComponentType<any>>;
};
// Aplicar o tenant como um caminho
const path = (tenant: string) => `/${tenant}/`;
// Verificar se o Item ou SubItem possuem uma property component e retornar o componente + rota
const getComponentInfo = (item: MenuSubItem, aliasProfile: string) => {
if (!!item.component) {
const localImport = item.component(aliasProfile, path(TENANT));
return { path: item.path, component: lazy(() => localImport) };
}
return null;
};
const getProfileRoutes = (filterRoutes: MenuItem[], aliasProfile: string) => {
// Essa é a minha lista com as rotas aplicadas no react-router
const localRoutes = [] as RouterConstruct[];
filterRoutes.forEach((route) => {
const component = getComponentInfo(route, aliasProfile);
if (route.path !== "" && component !== null) {
localRoutes.push(component);
}
route.subItems.forEach((subRoute) => {
const subComponent = getComponentInfo(subRoute, aliasProfile);
if (subComponent !== null) {
localRoutes.push(subComponent);
}
});
});
return localRoutes;
};
const useRoutesByProfile = () => {
const filterRoutes = useRouteFilter();
const [routes, setRoutes] = useState([] as RouterConstruct[]);
/*
Toda vez que o filtro de rotas, dado o perfil e tenant mudar,
execute a criação de rotas novamente
*/
useEffect(() => {
const user = getAuthUser();
if (!isEmpty(user)) {
setRoutes(getProfileRoutes(MenuItems, user.perfil));
}
}, [filterRoutes]);
return routes;
};
export default useRoutesByProfile;
Quase tudo ok, agora só dizer ao react-router
quais são as nossas rotas, aplicar o suspense
const createRoute = (x: RouterConstruct) => (
<Route exact key={x.path} path={x.path} component={x.component} />
);
const AppRouter = () => {
// Autenticação requerida em toda a aplicação
useAuth();
// Hook para roteamento dinâmico
const routes = useRoutesByProfile();
return (
<Router history={History}>
{/*
Error Boundary para caso hajam erros de importação dinâmicas e outros
relacionado ao ciclo de vida do react (mais funcional em prod)
*/}
<ErrorBoundary>
{/*
Este componente você pode ver no github, mas é basicamente um wrapper
para o React.Suspense com um loader customizado
*/}
<SuspenseLoader>
<HashRouter>
<Switch>
{/*
Mapeamento das rotas dinâmicas de acordo com os dados do nosso hook
*/}
{routes.map(createRoute)}
<Route component={NotFound} />
</Switch>
</HashRouter>
</SuspenseLoader>
</ErrorBoundary>
</Router>
);
};
Code Splitting
Feito isso, nosso controle de rotas está totalmente pronto, com as regras de Tenant e perfil aplicadas. Para fazer o funcionamento correto, você precisa fazer o import()
para o path correto e ter uma estrutura de pastas de acordo com os tenants e perfis disponíveis.
./src/modules
├── dashboard
│ ├── abcd
│ │ └── admin.page.tsx
│ ├── xkcb
│ │ └── admin.page.tsx
│ └── xpto
│ └── admin.page.tsx
└── users
└── index.page.tsx
E como faria caso não tenha perfil ou tenant específico? Apenas troque o import para o path exato do seu arquivo. Como a chamada do componente é uma função de dois parâmetros, você pode escolher por não utilizar e apenas retornar a string exata do seu componente.
Dessa forma, o seu frontend já está apto para trabalhar com code splitting. Deixo abaixo apenas um hack para caso seus assets estejam sendo servidos de um subdomínio diferente do qual o site está sendo acessado
const isDev = process.env.NODE_ENV === "development";
/*
URL de acesso dos assets do frontend, diferente do qual você acessa.
site: https://app.xpto.io/
*/
export const URL_ACCESS = `https://xpto-industries.io/${TENANT}/dashboard/${VERSION}/`;
export declare let __webpack_public_path__: string;
if (!isDev) {
/*
Define onde o webpack irá fazer o load dos `chunks` com dynamic import
*/
__webpack_public_path__ = URL_ACCESS;
}
Redux dinâmico
Se vamos modularizar a aplicação, a parte do redux também precisa ser modularizada. Nossa store deverá ter métodos de injetar os reducers de acordo com a tela que estão sendo acessados, e substituir o atual estado do reducer pelo novo estado com os novos reducers injetados. Tomei como base a resposta do Dan Abramov no StackOverflow, e fiz algumas adaptações para Typescript e utilizando hooks. Você pode observar a forma de como criar uma store com suporte a injeção de novos reducers
type Middleware = StoreEnhancer<
{
dispatch: unknown;
},
{}
>;
export type AsyncStore = Store<unknown, AnyAction> & {
dispatch: unknown;
asyncReducers: {
[key: string]: Reducer<unknown, any>;
};
injectReducer: (key: string, reducer: Reducer<any>) => AsyncStore;
};
const redux = (asyncReducers: any = {}) =>
combineReducers({
// reducer de autenticação
[ReducersAlias.AuthReducer]: authReducer,
...asyncReducers,
});
/*
Inicializar a store com os middlewares necessários sendo passados
como parâmetro.
Note que o `redux()` é uma função que pode receber ou não os reducers
assíncronos.
Após inserir-los na store, um .replaceReducer é invocado com os novos
reducers e o novo estado já está pronto para ser utilizado
*/
const initializeStore = (middleware: Middleware) => {
const store = createStore(redux(), middleware) as AsyncStore;
store.asyncReducers = {};
store.injectReducer = (key: string, reducer: Reducer<unknown, any>) => {
store.asyncReducers[key] = reducer;
store.replaceReducer(redux(store.asyncReducers));
return store;
};
return store;
};
// src/page.tsx - Onde iremos criar nosso wraper do estado do Redux
const sagasMiddleware = reduxSaga();
const middlewares = applyMiddleware(sagasMiddleware, logger);
const reduxStore = store(middlewares);
sagasMiddleware.run(sagasActions);
ReactDOM.render(
<StrictMode>
<Provider store={reduxStore}>
<AppRouter />
</Provider>
</StrictMode>,
document.getElementById("root")
);
Isso é o primeiro passo para a construção da store com injeção de reducers assíncronos. Agora só nos resta criar uma forma de injetar os nossos reducers logo que nosso componente for carregado e precisar fazer o acesso
import { useEffect } from "react";
import { Reducer } from "redux";
import { useStore } from "react-redux";
// Essa é a store cujo o código foi mostrado anteriormente
import { AsyncStore } from "@/store";
/*
Uma modificação legal a ser feita é mudar o método injectReducer
para receber um array `key: reducer` e poder acrescentar mais de
um reducer na nossa store
*/
const useInjectReducer = (key: string, reducer: Reducer<any>) => {
const store = useStore() as AsyncStore;
useEffect(() => {
store.injectReducer(key, reducer);
}, []);
};
E isso é tudo. Na hora de importar seu reducer, não esqueça de definir o estado inicial para o primeiro render não quebrar, pois nele não existirá o seu reducer ainda. E com isso os reducers serão acrescentados dinâmicamente ao nosso app.
Planejamento futuro
Uma coisa que ainda estou testando é fazer a inserção de actions no sagas dinamicamente, seguindo a mesma lógica de um reducer dinâmico, com isso, removeremos todos os assets estáticos do nosso código, e apenas quando o usuário interagir com um módulo da aplicação ele será "ativado".
Suporte a SSR. Apesar de resolver o problema, essa solução é falha quando se trabalha com SSR pois o React Lazy/Suspense não dão suporte, então será necessário trocar a forma de fazer o code splitting para uma biblioteca que dê tal suporte
Realizar testes de performance na aplicação. Como estou mudando a store em alguns renders, é possível que isso apresente lentidões futuras. Será preciso testar
Conclusão
Apesar de parecer muito código, depois que se entende o que deve fazer, tudo fica bem mais claro. A experiência de desenvolvimento com essa prática tem sido bem aceitável, apenas alguns glitches acontecem quando algum componente é atualizado e a tela fica branca, mas nada que fosse realmente impactar.
Se você precisa de uma aplicação que seja modularizada, algo próximo de um micro frontend, essa é uma prática que pode ajudar. Apesar de tudo o que foi feito aqui ter sido com React, creio que o mindset funcione para qualquer outro framework, apenas suas ferramentas serão diferentes.
O over engineering foi aceito para tal solução, infelizmente ainda não vi uma forma de reduzir a quantidade absurda de voltas e alguns boilerplates que isso requer, mas é um preço a se pagar dado a necessidade.
E é isso galerinha, espero que isso possa ajudar você a dar uma clareada na mente e fique como material de pesquisa. Quaisquer problemas, posta uma issue lá no repositório que a gente troca uma ideia xD