MultiTenants, controle de acesso e Code Splitting

Entendendo as técnicas para um frontend dinâmico e com controle de acesso

Table of Content

    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 MenuItem = {
    	component?: (profile: string, tenant?: string) => Promise<any>;
    	key: string;
    	path: string;
    	icon: string;
    	title: string;
    	tenants: string[];
    	subItems: MenuSubItem[];
    	tenantEnv?: string;
    	allowedProfiles: string[];
    };
    
    // Um sub item não pode ter sub itens, então iremos omitir este tipo
    export type MenuSubItem = Omit<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/index.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

    1. 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".

    2. 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

    3. 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

    Referências